📸 使用 Riverpod 实现头像立即上传
- 完整的实现方案
项目结构
lib/
├── main.dart
├── providers/
│ ├── avatar_provider.dart
│ └── providers.dart
├── services/
│ └── upload_service.dart
├── widgets/
│ └── avatar_upload_widget.dart
└── pages/
└── profile_page.dart
- Provider 定义
lib/providers/avatar_provider.dart
dart
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import '../services/upload_service.dart';
// 上传状态
enum AvatarUploadStatus {
idle, // 空闲
selecting, // 选择中
uploading, // 上传中
success, // 成功
error, // 失败
}
// 状态模型
class AvatarState {
final AvatarUploadStatus status;
final File? selectedImage;
final String? imageUrl;
final double progress;
final String? errorMessage;
AvatarState({
this.status = AvatarUploadStatus.idle,
this.selectedImage,
this.imageUrl,
this.progress = 0,
this.errorMessage,
});
AvatarState copyWith({
AvatarUploadStatus? status,
File? selectedImage,
String? imageUrl,
double? progress,
String? errorMessage,
}) {
return AvatarState(
status: status ?? this.status,
selectedImage: selectedImage ?? this.selectedImage,
imageUrl: imageUrl ?? this.imageUrl,
progress: progress ?? this.progress,
errorMessage: errorMessage ?? this.errorMessage,
);
}
bool get isIdle => status == AvatarUploadStatus.idle;
bool get isSelecting => status == AvatarUploadStatus.selecting;
bool get isUploading => status == AvatarUploadStatus.uploading;
bool get isSuccess => status == AvatarUploadStatus.success;
bool get hasError => status == AvatarUploadStatus.error;
}
// Notifier 类
class AvatarNotifier extends StateNotifier<AvatarState> {
final UploadService uploadService;
final ImagePicker imagePicker;
AvatarNotifier({
required this.uploadService,
required this.imagePicker,
}) : super(AvatarState());
// 选择图片并立即上传
Future<void> pickAndUploadImage({
required ImageSource source,
required String userId,
}) async {
try {
// 1. 选择图片
state = state.copyWith(
status: AvatarUploadStatus.selecting,
errorMessage: null,
);
final XFile? pickedFile = await imagePicker.pickImage(
source: source,
maxWidth: 800,
maxHeight: 800,
imageQuality: 85,
);
if (pickedFile == null) {
state = state.copyWith(status: AvatarUploadStatus.idle);
return;
}
final File imageFile = File(pickedFile.path);
// 2. 更新预览状态
state = state.copyWith(
status: AvatarUploadStatus.uploading,
selectedImage: imageFile,
progress: 0,
);
// 3. 立即上传
final String imageUrl = await uploadService.uploadAvatar(
imageFile: imageFile,
userId: userId,
onProgress: (double progress) {
// 更新进度
state = state.copyWith(progress: progress);
},
);
// 4. 上传成功
state = state.copyWith(
status: AvatarUploadStatus.success,
imageUrl: imageUrl,
progress: 1.0,
selectedImage: null, // 清除临时文件引用
);
} catch (error) {
// 5. 错误处理
state = state.copyWith(
status: AvatarUploadStatus.error,
errorMessage: error.toString(),
progress: 0,
);
// 可选:3秒后重置错误状态
Future.delayed(const Duration(seconds: 3), () {
if (state.hasError) {
state = state.copyWith(status: AvatarUploadStatus.idle);
}
});
}
}
// 清除错误
void clearError() {
if (state.hasError) {
state = state.copyWith(
status: AvatarUploadStatus.idle,
errorMessage: null,
);
}
}
// 手动设置头像URL(用于初始加载)
void setInitialAvatar(String? imageUrl) {
state = state.copyWith(imageUrl: imageUrl);
}
// 重置状态
void reset() {
state = AvatarState();
}
}
// Provider 定义
final avatarProvider = StateNotifierProvider<AvatarNotifier, AvatarState>(
(ref) {
return AvatarNotifier(
uploadService: ref.watch(uploadServiceProvider),
imagePicker: ImagePicker(),
);
},
);
lib/providers/providers.dart
dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'avatar_provider.dart';
// 导出所有 provider
export 'avatar_provider.dart';
// 其他依赖的 provider
final uploadServiceProvider = Provider<UploadService>((ref) {
return UploadService();
});
- 上传服务
lib/services/upload_service.dart
dart
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
class UploadService {
// 模拟上传,实际项目中替换为真实API
Future<String> uploadAvatar({
required File imageFile,
required String userId,
required void Function(double progress) onProgress,
}) async {
try {
// 模拟上传延迟
await Future.delayed(const Duration(milliseconds: 100));
// 模拟进度更新
for (int i = 0; i <= 10; i++) {
await Future.delayed(const Duration(milliseconds: 100));
onProgress(i / 10);
}
// 实际项目中,这里应该是真实的API调用
/*
final uri = Uri.parse('https://your-api.com/upload/avatar');
final request = http.MultipartRequest('POST', uri)
..files.add(await http.MultipartFile.fromPath(
'avatar',
imageFile.path,
))
..fields['userId'] = userId;
final streamedResponse = await request.send();
// 监听进度
streamedResponse.stream.listen(
(List<int> chunk) {
final total = streamedResponse.contentLength ?? 0;
final uploaded = chunk.length;
onProgress(uploaded / total);
},
);
final response = await http.Response.fromStream(streamedResponse);
if (response.statusCode == 200) {
final jsonResponse = json.decode(response.body);
return jsonResponse['data']['url'];
} else {
throw Exception('上传失败: ${response.statusCode}');
}
*/
// 模拟返回的图片URL
await Future.delayed(const Duration(milliseconds: 500));
return 'https://picsum.photos/400/400?random=${DateTime.now().millisecondsSinceEpoch}';
} catch (e) {
throw Exception('上传失败: $e');
}
}
}
- 上传组件
lib/widgets/avatar_upload_widget.dart
dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../providers/avatar_provider.dart';
class AvatarUploadWidget extends ConsumerWidget {
final String userId;
final double size;
final bool showEditButton;
final VoidCallback? onUploadSuccess;
const AvatarUploadWidget({
Key? key,
required this.userId,
this.size = 100,
this.showEditButton = true,
this.onUploadSuccess,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(avatarProvider);
return Center(
child: Stack(
alignment: Alignment.bottomRight,
children: [
// 头像显示
_buildAvatar(state, context),
// 编辑按钮
if (showEditButton && !state.isUploading)
_buildEditButton(context, ref),
// 上传进度指示器
if (state.isUploading) _buildProgressIndicator(state),
// 错误提示
if (state.hasError) _buildErrorOverlay(state, ref),
],
),
);
}
Widget _buildAvatar(AvatarState state, BuildContext context) {
// 如果有选中的图片(正在上传中),显示预览
if (state.selectedImage != null && state.isUploading) {
return _buildImagePreview(state.selectedImage!);
}
// 如果有上传成功的图片URL,显示网络图片
if (state.imageUrl != null && state.imageUrl!.isNotEmpty) {
return _buildNetworkImage(state.imageUrl!);
}
// 默认头像
return _buildDefaultAvatar();
}
Widget _buildImagePreview(File imageFile) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: Colors.blue.withOpacity(0.3),
width: 2,
),
),
child: ClipOval(
child: Image.file(
imageFile,
fit: BoxFit.cover,
width: size,
height: size,
),
),
);
}
Widget _buildNetworkImage(String imageUrl) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: Colors.grey.shade300,
width: 2,
),
),
child: ClipOval(
child: CachedNetworkImage(
imageUrl: imageUrl,
width: size,
height: size,
fit: BoxFit.cover,
placeholder: (context, url) => Center(
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.blue),
),
),
errorWidget: (context, url, error) => _buildDefaultAvatar(),
),
),
);
}
Widget _buildDefaultAvatar() {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.grey.shade200,
border: Border.all(
color: Colors.grey.shade300,
width: 2,
),
),
child: Icon(
Icons.person,
size: size * 0.5,
color: Colors.grey.shade400,
),
);
}
Widget _buildEditButton(BuildContext context, WidgetRef ref) {
return GestureDetector(
onTap: () => _showImageSourceDialog(context, ref),
child: Container(
width: size * 0.3,
height: size * 0.3,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.blue,
border: Border.all(
color: Colors.white,
width: 2,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: Offset(0, 2),
),
],
),
child: const Icon(
Icons.camera_alt,
size: 18,
color: Colors.white,
),
),
);
}
Widget _buildProgressIndicator(AvatarState state) {
return Positioned.fill(
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.black.withOpacity(0.5),
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: size * 0.4,
height: size * 0.4,
child: CircularProgressIndicator(
value: state.progress,
strokeWidth: 3,
backgroundColor: Colors.white30,
valueColor: AlwaysStoppedAnimation<Color>(Colors.blue),
),
),
SizedBox(height: 8),
Text(
'${(state.progress * 100).toInt()}%',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
);
}
Widget _buildErrorOverlay(AvatarState state, WidgetRef ref) {
return Positioned.fill(
child: GestureDetector(
onTap: () => ref.read(avatarProvider.notifier).clearError(),
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.red.withOpacity(0.8),
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
color: Colors.white,
size: 30,
),
SizedBox(height: 4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'上传失败',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
maxLines: 2,
),
),
],
),
),
),
),
);
}
void _showImageSourceDialog(BuildContext context, WidgetRef ref) {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
Text(
'更换头像',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.grey.shade800,
),
),
const SizedBox(height: 16),
_buildOptionButton(
context: context,
icon: Icons.camera_alt,
text: '拍照',
onTap: () {
Navigator.pop(context);
_uploadFromSource(ImageSource.camera, ref);
},
),
_buildOptionButton(
context: context,
icon: Icons.photo_library,
text: '从相册选择',
onTap: () {
Navigator.pop(context);
_uploadFromSource(ImageSource.gallery, ref);
},
),
const SizedBox(height: 16),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
],
),
),
);
}
Widget _buildOptionButton({
required BuildContext context,
required IconData icon,
required String text,
required VoidCallback onTap,
}) {
return ListTile(
leading: Icon(icon, color: Colors.blue),
title: Text(text),
onTap: onTap,
);
}
void _uploadFromSource(ImageSource source, WidgetRef ref) {
ref.read(avatarProvider.notifier).pickAndUploadImage(
source: source,
userId: userId,
);
// 监听上传成功
ref.listen<AvatarState>(avatarProvider, (previous, next) {
if (next.isSuccess && onUploadSuccess != null) {
onUploadSuccess!();
}
});
}
}
- 个人资料页面
lib/pages/profile_page.dart
dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../widgets/avatar_upload_widget.dart';
import '../providers/avatar_provider.dart';
class ProfilePage extends ConsumerStatefulWidget {
const ProfilePage({Key? key}) : super(key: key);
@override
_ProfilePageState createState() => _ProfilePageState();
}
class _ProfilePageState extends ConsumerState<ProfilePage> {
final String userId = 'user_123'; // 实际项目中从登录状态获取
@override
void initState() {
super.initState();
// 页面初始化时加载现有头像
_loadInitialAvatar();
}
Future<void> _loadInitialAvatar() async {
// 模拟从服务器获取现有头像URL
await Future.delayed(const Duration(milliseconds: 500));
// 这里应该调用API获取用户头像URL
// final response = await api.getUserAvatar(userId);
// ref.read(avatarProvider.notifier).setInitialAvatar(response.avatarUrl);
// 模拟数据
ref.read(avatarProvider.notifier).setInitialAvatar(
'https://picsum.photos/400/400?random=123',
);
}
@override
Widget build(BuildContext context) {
final state = ref.watch(avatarProvider);
return Scaffold(
appBar: AppBar(
title: const Text('个人资料'),
centerTitle: true,
elevation: 0,
),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 头像上传区域
_buildAvatarSection(state),
const SizedBox(height: 30),
// 个人信息
_buildInfoSection(),
const SizedBox(height: 30),
// 上传状态显示
if (state.isUploading) _buildUploadStatus(state),
if (state.hasError) _buildErrorStatus(state, ref),
if (state.isSuccess) _buildSuccessStatus(),
],
),
),
),
);
}
Widget _buildAvatarSection(AvatarState state) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
Text(
'个人头像',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.grey.shade800,
),
),
const SizedBox(height: 20),
AvatarUploadWidget(
userId: userId,
size: 120,
onUploadSuccess: () {
// 上传成功后的回调
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('头像更新成功!'),
backgroundColor: Colors.green,
),
);
},
),
const SizedBox(height: 16),
Text(
state.isUploading ? '正在上传...' : '点击右下角相机更换头像',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
],
),
),
);
}
Widget _buildInfoSection() {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'个人信息',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.grey.shade800,
),
),
const SizedBox(height: 20),
_buildInfoItem('用户名', 'Flutter用户'),
_buildInfoItem('邮箱', 'user@example.com'),
_buildInfoItem('注册时间', '2024-01-01'),
],
),
),
);
}
Widget _buildInfoItem(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Expanded(
flex: 2,
child: Text(
label,
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade600,
),
),
),
Expanded(
flex: 3,
child: Text(
value,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
Widget _buildUploadStatus(AvatarState state) {
return Card(
color: Colors.blue.shade50,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
CircularProgressIndicator(
value: state.progress,
strokeWidth: 3,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'正在上传头像...',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.blue.shade800,
),
),
const SizedBox(height: 4),
LinearProgressIndicator(
value: state.progress,
backgroundColor: Colors.blue.shade200,
valueColor: AlwaysStoppedAnimation<Color>(Colors.blue),
),
const SizedBox(height: 4),
Text(
'${(state.progress * 100).toInt()}%',
style: TextStyle(
fontSize: 12,
color: Colors.blue.shade600,
),
),
],
),
),
],
),
),
);
}
Widget _buildErrorStatus(AvatarState state, WidgetRef ref) {
return Card(
color: Colors.red.shade50,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.error_outline, color: Colors.red),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'上传失败',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.red.shade800,
),
),
Text(
state.errorMessage ?? '未知错误',
style: TextStyle(
fontSize: 12,
color: Colors.red.shade600,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
TextButton(
onPressed: () => ref.read(avatarProvider.notifier).clearError(),
child: Text('关闭'),
),
],
),
),
);
}
Widget _buildSuccessStatus() {
return Card(
color: Colors.green.shade50,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.check_circle, color: Colors.green),
const SizedBox(width: 16),
Expanded(
child: Text(
'头像更新成功!',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.green.shade800,
),
),
),
],
),
),
);
}
}
- 主程序入口
lib/main.dart
dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'pages/profile_page.dart';
import 'providers/providers.dart';
void main() {
runApp(
const ProviderScope(
child: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '头像上传示例',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
),
),
home: const ProfilePage(),
debugShowCheckedModeBanner: false,
);
}
}
- Pubspec.yaml 配置
yaml
name: avatar_upload_example
description: A Flutter avatar upload example with Riverpod
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ">=2.19.0 <3.0.0"
dependencies:
flutter:
sdk: flutter
# 状态管理
flutter_riverpod: ^2.3.6
# 图片选择
image_picker: ^1.0.4
# 网络请求
http: ^0.13.5
# 网络图片缓存
cached_network_image: ^3.2.3
# 图片裁剪(可选)
image_cropper: ^5.0.0
flutter:
uses-material-design: true
# 配置 assets
assets:
- assets/images/
# iOS 配置
# 在 ios/Runner/Info.plist 中添加:
# <key>NSCameraUsageDescription</key>
# <string>需要相机权限来拍摄头像照片</string>
# <key>NSPhotoLibraryUsageDescription</key>
# <string>需要相册权限来选择头像照片</string>
# Android 配置
# 在 android/app/src/main/AndroidManifest.xml 中添加:
# <uses-permission android:name="android.permission.CAMERA"/>
# <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
# <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
- 关键特性总结
✅ 立即上传流程
- 选择图片 → 自动进入选择状态
- 显示预览 → 立即显示选中图片
- 开始上传 → 自动开始上传并显示进度
- 上传成功 → 更新头像并显示成功状态
- 错误处理 → 显示错误信息并提供重试
🔄 状态管理
· Riverpod 管理整个上传状态
· 实时更新 上传进度
· 自动重置 错误状态
🎯 用户体验
· 流畅的上传进度显示
· 清晰的错误提示
· 成功状态反馈
· 优雅的加载和过渡效果
🛠️ 扩展建议
- 添加图片裁剪功能
- 实现断点续传
- 支持多图片上传
- 添加图片压缩功能
- 集成云存储(如 Firebase Storage、阿里云 OSS)
这个实现方案:选择相册或拍照 → 立即上传 → 上传成功更新页面。所有的状态都是实时更新的,用户可以看到完整的上传过程。