修复用户端个人头像展示
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(
|
||||
|
||||
54
apps/user_app/test/data/api_client_test.dart
Normal file
54
apps/user_app/test/data/api_client_test.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
// 功能描述:验证受保护头像二进制接口的鉴权读取和异常处理。
|
||||
// 版本:1.1.0
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:user_app/data/services/api_client.dart';
|
||||
|
||||
/// 验证头像字节、鉴权请求头和资源不存在状态。
|
||||
void main() {
|
||||
test('头像接口携带令牌并返回二进制内容', () async {
|
||||
final expectedBytes = Uint8List.fromList(const [1, 2, 3]);
|
||||
final client = ApiClient(
|
||||
() => 'JWT test-token',
|
||||
baseUrl: 'https://api.example.com',
|
||||
client: MockClient((request) async {
|
||||
expect(request.url.path, '/heqi/client/v1/user/auth/avatar');
|
||||
expect(request.headers['authorization'], 'JWT test-token');
|
||||
return http.Response.bytes(expectedBytes, 200);
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
await client.getBytes('/heqi/client/v1/user/auth/avatar'),
|
||||
expectedBytes,
|
||||
);
|
||||
});
|
||||
|
||||
test('头像不存在时返回空值', () async {
|
||||
final client = ApiClient(
|
||||
() => 'JWT test-token',
|
||||
baseUrl: 'https://api.example.com',
|
||||
client: MockClient((request) async => http.Response('', 404)),
|
||||
);
|
||||
|
||||
expect(await client.getBytes('/avatar'), isNull);
|
||||
});
|
||||
|
||||
test('头像接口异常时返回中文错误', () async {
|
||||
final client = ApiClient(
|
||||
() => 'JWT test-token',
|
||||
baseUrl: 'https://api.example.com',
|
||||
client: MockClient((request) async => http.Response('', 500)),
|
||||
);
|
||||
|
||||
expect(
|
||||
() => client.getBytes('/avatar'),
|
||||
throwsA(
|
||||
isA<ApiException>().having((error) => error.message, 'message', '头像加载失败'),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
71
apps/user_app/test/ui/profile_avatar_test.dart
Normal file
71
apps/user_app/test/ui/profile_avatar_test.dart
Normal file
@@ -0,0 +1,71 @@
|
||||
// 功能描述:验证用户头像的真实图片、空值占位和加载失败回退状态。
|
||||
// 版本:1.0.0
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:user_app/data/services/api_client.dart';
|
||||
import 'package:user_app/ui/features/profile/profile_avatar.dart';
|
||||
|
||||
/// 验证头像组件在不同图片状态下保持可用。
|
||||
void main() {
|
||||
/// 构建统一头像测试宿主。
|
||||
Widget testHost({
|
||||
required String name,
|
||||
required Uint8List? imageBytes,
|
||||
}) => MaterialApp(
|
||||
home: Scaffold(
|
||||
body: ProfileAvatar(
|
||||
name: name,
|
||||
imageBytes: imageBytes,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
testWidgets('有头像时使用圆形裁剪图片', (tester) async {
|
||||
final imageBytes = Uint8List.fromList(
|
||||
base64Decode(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAF/gL+X8l7WQAAAABJRU5ErkJggg==',
|
||||
),
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
testHost(
|
||||
name: 'cyyy',
|
||||
imageBytes: imageBytes,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final image = tester.widget<Image>(find.byKey(const Key('profile-avatar-image')));
|
||||
expect(image.fit, BoxFit.cover);
|
||||
expect(image.image, isA<MemoryImage>());
|
||||
});
|
||||
|
||||
testWidgets('头像为空时显示用户名首字母', (tester) async {
|
||||
await tester.pumpWidget(testHost(name: 'cyyy', imageBytes: null));
|
||||
|
||||
expect(find.text('c'), findsOneWidget);
|
||||
expect(find.byKey(const Key('profile-avatar-image')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('头像加载失败时回退用户名首字母', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
testHost(
|
||||
name: 'cyyy',
|
||||
imageBytes: Uint8List.fromList(const [0]),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('c'), findsOneWidget);
|
||||
});
|
||||
|
||||
test('头像接口失败时安全返回空值', () async {
|
||||
final result = await loadProfileAvatarSafely(
|
||||
() => throw const ApiException(500, '头像加载失败'),
|
||||
);
|
||||
|
||||
expect(result, isNull);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user