修复用户端个人头像展示
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../../domain/models/client_models.dart';
|
||||
import '../services/api_client.dart';
|
||||
|
||||
@@ -39,6 +41,9 @@ class ClientRepository {
|
||||
Future<UserProfile> profile() async =>
|
||||
UserProfile.fromJson(jsonMap(await _api.get('$root/auth/profile')));
|
||||
|
||||
/// 通过用户端鉴权接口读取当前登录用户的头像二进制内容。
|
||||
Future<Uint8List?> profileAvatar() => _api.getBytes('$root/auth/avatar');
|
||||
|
||||
Future<WalletSummary> wallet() async =>
|
||||
WalletSummary.fromJson(jsonMap(await _api.get('$root/wallet')));
|
||||
|
||||
@@ -93,15 +98,17 @@ class ClientRepository {
|
||||
required String channel,
|
||||
required String payType,
|
||||
String openid = '',
|
||||
}) async => jsonMap(await _api.post(
|
||||
'$root/$business/orders/$identity/pay',
|
||||
body: {
|
||||
'request_no': requestNo,
|
||||
'channel': channel,
|
||||
}) async => jsonMap(
|
||||
await _api.post(
|
||||
'$root/$business/orders/$identity/pay',
|
||||
body: {
|
||||
'request_no': requestNo,
|
||||
'channel': channel,
|
||||
'pay_type': payType,
|
||||
if (openid.isNotEmpty) 'openid': openid,
|
||||
},
|
||||
));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> createRefund({
|
||||
required String business,
|
||||
@@ -110,11 +117,14 @@ class ClientRepository {
|
||||
required String reason,
|
||||
required List<Map<String, Object?>> items,
|
||||
}) async {
|
||||
await _api.post('$root/$business/orders/$identity/refunds', body: {
|
||||
'request_no': requestNo,
|
||||
'reason': reason,
|
||||
'items': items,
|
||||
});
|
||||
await _api.post(
|
||||
'$root/$business/orders/$identity/refunds',
|
||||
body: {
|
||||
'request_no': requestNo,
|
||||
'reason': reason,
|
||||
'items': items,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, Object?>?> serviceRelation() async {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 功能描述:封装用户端 HTTP 请求,并将服务端错误转换为安全、可读的中文提示。
|
||||
// 版本:1.1.0
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
@@ -104,6 +105,20 @@ class ApiClient {
|
||||
Future<Object?> get(String path, {bool authenticated = true}) =>
|
||||
_send('GET', path, authenticated: authenticated);
|
||||
|
||||
/// 读取需要鉴权的二进制资源;资源不存在时返回空值。
|
||||
Future<Uint8List?> getBytes(String path) async {
|
||||
final request = http.Request('GET', Uri.parse('$baseUrl$path'));
|
||||
request.headers['accept'] = 'image/jpeg, image/png';
|
||||
final token = _tokenProvider();
|
||||
if (token.isNotEmpty) request.headers['authorization'] = token;
|
||||
final response = await _sendRequest(request);
|
||||
if (response.statusCode == 404) return null;
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw ApiException(response.statusCode, '头像加载失败');
|
||||
}
|
||||
return response.bodyBytes.isEmpty ? null : response.bodyBytes;
|
||||
}
|
||||
|
||||
Future<Object?> post(
|
||||
String path, {
|
||||
Map<String, Object?>? body,
|
||||
|
||||
77
apps/user_app/lib/ui/features/profile/profile_avatar.dart
Normal file
77
apps/user_app/lib/ui/features/profile/profile_avatar.dart
Normal file
@@ -0,0 +1,77 @@
|
||||
// 功能描述:展示用户真实头像,并在头像为空或加载失败时回退用户名首字母。
|
||||
// 版本:1.1.0
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 安全读取头像;失败时返回空值,确保个人中心可继续展示其他资料。
|
||||
Future<Uint8List?> loadProfileAvatarSafely(
|
||||
Future<Uint8List?> Function() loader,
|
||||
) async {
|
||||
try {
|
||||
return await loader();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户资料头像,保持圆形裁剪并提供稳定的文字占位状态。
|
||||
class ProfileAvatar extends StatelessWidget {
|
||||
const ProfileAvatar({
|
||||
required this.name,
|
||||
required this.imageBytes,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final Uint8List? imageBytes;
|
||||
|
||||
/// 从用户名生成占位字符,空名称统一显示“用”。
|
||||
String get _fallbackText {
|
||||
final normalized = name.trim();
|
||||
return normalized.isEmpty ? '用' : normalized.substring(0, 1);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bytes = imageBytes;
|
||||
final hasImage = bytes != null && bytes.isNotEmpty;
|
||||
final fallback = Center(
|
||||
child: Text(
|
||||
_fallbackText,
|
||||
key: const Key('profile-avatar-fallback'),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return Semantics(
|
||||
image: true,
|
||||
label: '${name.trim().isEmpty ? '用户' : name.trim()}头像',
|
||||
child: ExcludeSemantics(
|
||||
child: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: !hasImage
|
||||
? fallback
|
||||
: Image(
|
||||
key: const Key('profile-avatar-image'),
|
||||
image: MemoryImage(bytes),
|
||||
width: 56,
|
||||
height: 56,
|
||||
fit: BoxFit.cover,
|
||||
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) =>
|
||||
wasSynchronouslyLoaded || frame != null ? child : fallback,
|
||||
errorBuilder: (context, error, stackTrace) => fallback,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
// 功能描述:展示用户资料、头像、钱包概览及账户服务入口。
|
||||
// 版本:1.2.0
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
@@ -6,7 +10,9 @@ import '../../../app/dependencies.dart';
|
||||
import '../../../data/repositories/client_repository.dart';
|
||||
import '../../../domain/models/client_models.dart';
|
||||
import '../../core/widgets.dart';
|
||||
import 'profile_avatar.dart';
|
||||
|
||||
/// 用户端个人中心页面。
|
||||
class ProfilePage extends StatefulWidget {
|
||||
const ProfilePage({required this.session, required this.repository, super.key});
|
||||
|
||||
@@ -17,8 +23,9 @@ class ProfilePage extends StatefulWidget {
|
||||
State<ProfilePage> createState() => _ProfilePageState();
|
||||
}
|
||||
|
||||
/// 管理个人资料加载及账户服务操作。
|
||||
class _ProfilePageState extends State<ProfilePage> {
|
||||
late Future<(UserProfile, WalletSummary)> _future;
|
||||
late Future<(UserProfile, WalletSummary, Uint8List?)> _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -26,8 +33,16 @@ class _ProfilePageState extends State<ProfilePage> {
|
||||
_future = _load();
|
||||
}
|
||||
|
||||
Future<(UserProfile, WalletSummary)> _load() async =>
|
||||
(await widget.repository.profile(), await widget.repository.wallet());
|
||||
/// 并行加载资料和钱包,并在资料存在头像时读取受保护图片内容。
|
||||
Future<(UserProfile, WalletSummary, Uint8List?)> _load() async {
|
||||
final profileFuture = widget.repository.profile();
|
||||
final walletFuture = widget.repository.wallet();
|
||||
final profile = await profileFuture;
|
||||
final avatarFuture = profile.avatar.trim().isEmpty
|
||||
? Future<Uint8List?>.value()
|
||||
: loadProfileAvatarSafely(widget.repository.profileAvatar);
|
||||
return (profile, await walletFuture, await avatarFuture);
|
||||
}
|
||||
|
||||
Future<void> _addAddress() async {
|
||||
final controller = TextEditingController();
|
||||
@@ -87,7 +102,7 @@ class _ProfilePageState extends State<ProfilePage> {
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: const Text('我的')),
|
||||
body: FutureBuilder<(UserProfile, WalletSummary)>(
|
||||
body: FutureBuilder<(UserProfile, WalletSummary, Uint8List?)>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
@@ -100,18 +115,16 @@ class _ProfilePageState extends State<ProfilePage> {
|
||||
}
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final (profile, wallet) = snapshot.data!;
|
||||
final (profile, wallet, avatarBytes) = snapshot.data!;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 32),
|
||||
children: [
|
||||
SurfaceSection(
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 28,
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
foregroundColor: Theme.of(context).colorScheme.primary,
|
||||
child: Text(profile.name.isEmpty ? '用' : profile.name.substring(0, 1)),
|
||||
ProfileAvatar(
|
||||
name: profile.name,
|
||||
imageBytes: avatarBytes,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
|
||||
Reference in New Issue
Block a user