feat: align shared app foundations
This commit is contained in:
1 parent
bd6bb0ffce
commit
c5653a3699
21 files changed
+894
-66
No files matched your search
@@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../common/router/app_router.dart';
|
||||
import '../common/lifecycle/refresh_coordinator.dart';
|
||||
import '../common/theme/jinzhi_theme.dart';
|
||||
import '../common/theme/spacing.dart';
|
||||
import '../common/theme/theme_controller.dart';
|
||||
@@ -13,6 +14,9 @@ class JinzhiApp extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Keep the app-wide foreground refresh coordinator alive. Features opt in
|
||||
// by registering only while their screen is visible.
|
||||
ref.watch(refreshCoordinatorProvider);
|
||||
final router = ref.watch(routerProvider);
|
||||
final mode = ref.watch(themeModeControllerProvider);
|
||||
// DM Sans 作主字体(拉丁/数字),中文走系统级联(iOS 苹方 / Android 厂商
|
||||
|
||||
@@ -4,12 +4,18 @@ import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../common/services/device_header_service.dart';
|
||||
import '../common/storage/secure_identity_storage.dart';
|
||||
import '../common/storage/storage_service.dart';
|
||||
import '../common/widgets/easy_refresh_localization.dart';
|
||||
import 'app.dart';
|
||||
|
||||
Future<Widget> bootstrap() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
configureEasyRefreshChineseLocalization();
|
||||
await StorageService.instance;
|
||||
await SecureIdentityStorage(
|
||||
StorageService.to.preferences,
|
||||
).restoreAccessToken();
|
||||
if (!kIsWeb) {
|
||||
if (Platform.isAndroid) {
|
||||
// Before consent this only initializes app version and channel. The
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
typedef RefreshCallback = FutureOr<void> Function();
|
||||
|
||||
/// Coordinates safe refresh points without owning any feature data.
|
||||
///
|
||||
/// A screen registers its callback while it is visible. Registered callbacks
|
||||
/// run when the app returns to the foreground; callers can also request a
|
||||
/// manual refresh through the returned handle.
|
||||
class RefreshCoordinator with WidgetsBindingObserver {
|
||||
RefreshCoordinator() {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
final Set<_RefreshRegistration> _registrations = {};
|
||||
AppLifecycleState _lifecycleState = AppLifecycleState.resumed;
|
||||
|
||||
bool get isForeground => _lifecycleState == AppLifecycleState.resumed;
|
||||
|
||||
RefreshRegistration register({
|
||||
required RefreshCallback onRefresh,
|
||||
bool active = true,
|
||||
}) {
|
||||
final registration = _RefreshRegistration(onRefresh, active);
|
||||
_registrations.add(registration);
|
||||
return RefreshRegistration._(this, registration);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
_lifecycleState = state;
|
||||
if (state == AppLifecycleState.resumed) refreshVisible();
|
||||
}
|
||||
|
||||
void refreshVisible() {
|
||||
for (final registration in _registrations) {
|
||||
if (registration.active) unawaited(_run(registration));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _run(_RefreshRegistration registration) async {
|
||||
try {
|
||||
await registration.onRefresh();
|
||||
} catch (_) {
|
||||
// Refresh failures belong to the owning feature's state/UI.
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_registrations.clear();
|
||||
}
|
||||
}
|
||||
|
||||
class RefreshRegistration {
|
||||
RefreshRegistration._(this._coordinator, this._registration);
|
||||
|
||||
final RefreshCoordinator _coordinator;
|
||||
final _RefreshRegistration _registration;
|
||||
|
||||
bool get active => _registration.active;
|
||||
|
||||
set active(bool value) => _registration.active = value;
|
||||
|
||||
void refreshNow() => _coordinator._run(_registration);
|
||||
|
||||
void dispose() => _coordinator._registrations.remove(_registration);
|
||||
}
|
||||
|
||||
class _RefreshRegistration {
|
||||
_RefreshRegistration(this.onRefresh, this.active);
|
||||
|
||||
final RefreshCallback onRefresh;
|
||||
bool active;
|
||||
}
|
||||
|
||||
final refreshCoordinatorProvider = Provider<RefreshCoordinator>((ref) {
|
||||
final coordinator = RefreshCoordinator();
|
||||
ref.onDispose(coordinator.dispose);
|
||||
return coordinator;
|
||||
});
|
||||
@@ -2,8 +2,11 @@ import 'package:dio/dio.dart';
|
||||
|
||||
import '../services/device_header_service.dart';
|
||||
import '../storage/secure_identity_storage.dart';
|
||||
import '../storage/storage_service.dart';
|
||||
|
||||
final _defaultIdentityStorage = SecureIdentityStorage();
|
||||
final _defaultIdentityStorage = SecureIdentityStorage(
|
||||
StorageService.to.preferences,
|
||||
);
|
||||
|
||||
Future<void> saveJwt(String token) => _defaultIdentityStorage.saveJwt(token);
|
||||
Future<void> clearJwt() => _defaultIdentityStorage.clearJwt();
|
||||
|
||||
@@ -54,22 +54,43 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
ShellRoute(
|
||||
builder: (context, state, child) =>
|
||||
TabShell(location: state.matchedLocation, child: child),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: AppRoutes.assets,
|
||||
builder: (ctx, s) => const AssetsPage(),
|
||||
StatefulShellRoute.indexedStack(
|
||||
builder: (context, state, navigationShell) => TabShell(
|
||||
location: state.matchedLocation,
|
||||
navigationShell: navigationShell,
|
||||
),
|
||||
branches: [
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: AppRoutes.assets,
|
||||
builder: (ctx, s) => const AssetsPage(),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.market,
|
||||
builder: (ctx, s) => const MarketPage(),
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: AppRoutes.market,
|
||||
builder: (ctx, s) => const MarketPage(),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(path: AppRoutes.news, builder: (ctx, s) => const NewsPage()),
|
||||
GoRoute(
|
||||
path: AppRoutes.profile,
|
||||
builder: (ctx, s) => const ProfilePage(),
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: AppRoutes.news,
|
||||
builder: (ctx, s) => const NewsPage(),
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: AppRoutes.profile,
|
||||
builder: (ctx, s) => const ProfilePage(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -8,9 +8,13 @@ import '../widgets/adaptive.dart';
|
||||
import 'app_router.dart';
|
||||
|
||||
class TabShell extends StatefulWidget {
|
||||
const TabShell({super.key, required this.child, required this.location});
|
||||
const TabShell({
|
||||
super.key,
|
||||
required this.navigationShell,
|
||||
required this.location,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final StatefulNavigationShell navigationShell;
|
||||
final String location;
|
||||
|
||||
@override
|
||||
@@ -22,10 +26,7 @@ class _TabShellState extends State<TabShell> {
|
||||
|
||||
bool _showCompactHeader = false;
|
||||
|
||||
static const _tabs = ['/assets', '/market', '/news', '/profile'];
|
||||
|
||||
int get _index =>
|
||||
_tabs.indexWhere((t) => widget.location.startsWith(t)).clamp(0, 3);
|
||||
int get _index => widget.navigationShell.currentIndex;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant TabShell oldWidget) {
|
||||
@@ -104,7 +105,7 @@ class _TabShellState extends State<TabShell> {
|
||||
color: cs.background,
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: _handleScrollNotification,
|
||||
child: Stack(children: [Positioned.fill(child: widget.child)]),
|
||||
child: widget.navigationShell,
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
@@ -129,7 +130,7 @@ class _TabShellState extends State<TabShell> {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
for (var i = 0; i < _tabs.length; i++)
|
||||
for (var i = 0; i < _labels.length; i++)
|
||||
Expanded(
|
||||
child: _TabButton(
|
||||
icon: i == _index
|
||||
@@ -137,7 +138,11 @@ class _TabShellState extends State<TabShell> {
|
||||
: _outlineIcons[i],
|
||||
label: _labels[i],
|
||||
selected: i == _index,
|
||||
onTap: () => context.go(_tabs[i]),
|
||||
onTap: () =>
|
||||
widget.navigationShell.goBranch(
|
||||
i,
|
||||
initialLocation: i == _index,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,21 +1,98 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
const _jwtKey = 'jwt_token';
|
||||
import 'storage_keys.dart';
|
||||
import 'storage_service.dart';
|
||||
|
||||
abstract interface class SecureKeyValueStorage {
|
||||
Future<String?> read(String key);
|
||||
Future<void> write(String key, String value);
|
||||
Future<void> delete(String key);
|
||||
}
|
||||
|
||||
class FlutterSecureKeyValueStorage implements SecureKeyValueStorage {
|
||||
const FlutterSecureKeyValueStorage();
|
||||
|
||||
static const _storage = FlutterSecureStorage();
|
||||
|
||||
@override
|
||||
Future<String?> read(String key) => _storage.read(key: key);
|
||||
@override
|
||||
Future<void> write(String key, String value) =>
|
||||
_storage.write(key: key, value: value);
|
||||
@override
|
||||
Future<void> delete(String key) => _storage.delete(key: key);
|
||||
}
|
||||
|
||||
/// Sensitive identity values stay behind one injectable storage boundary.
|
||||
/// Legacy plaintext preferences are migrated only after a secure write works.
|
||||
class SecureIdentityStorage {
|
||||
SecureIdentityStorage({FlutterSecureStorage? storage})
|
||||
: _storage = storage ?? const FlutterSecureStorage();
|
||||
SecureIdentityStorage(
|
||||
this._preferences, {
|
||||
SecureKeyValueStorage secureStorage = const FlutterSecureKeyValueStorage(),
|
||||
}) : _storage = secureStorage;
|
||||
|
||||
final FlutterSecureStorage _storage;
|
||||
static const _accessTokenKey = 'access_token';
|
||||
static const _legacySecureJwtKey = 'jwt_token';
|
||||
|
||||
Future<void> saveJwt(String token) =>
|
||||
_storage.write(key: _jwtKey, value: token);
|
||||
Future<void> clearJwt() => _storage.delete(key: _jwtKey);
|
||||
Future<String?> readJwt() => _storage.read(key: _jwtKey);
|
||||
final SharedPreferences _preferences;
|
||||
final SecureKeyValueStorage _storage;
|
||||
|
||||
Future<String?> restoreAccessToken() async {
|
||||
final current = _normalized(await _storage.read(_accessTokenKey));
|
||||
final legacyPreferences = _normalized(
|
||||
_preferences.getString(storageLegacyAccessToken),
|
||||
);
|
||||
final legacySecure = current == null
|
||||
? _normalized(await _storage.read(_legacySecureJwtKey))
|
||||
: null;
|
||||
final token = current ?? legacyPreferences ?? legacySecure;
|
||||
if (current == null && token != null) {
|
||||
await _storage.write(_accessTokenKey, token);
|
||||
}
|
||||
await _storage.delete(_legacySecureJwtKey);
|
||||
await _preferences.remove(storageLegacyAccessToken);
|
||||
if (token == null) {
|
||||
await _preferences.remove(storageAuthSessionActive);
|
||||
} else {
|
||||
await _preferences.setBool(storageAuthSessionActive, true);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
Future<String?> readJwt() async {
|
||||
final token = _normalized(await _storage.read(_accessTokenKey));
|
||||
if (token == null || _preferences.containsKey(storageLegacyAccessToken)) {
|
||||
return restoreAccessToken();
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
Future<void> saveJwt(String token) async {
|
||||
final normalized = _normalized(token);
|
||||
if (normalized == null) {
|
||||
throw ArgumentError.value(token, 'token', 'must not be empty');
|
||||
}
|
||||
await _storage.write(_accessTokenKey, normalized);
|
||||
await _storage.delete(_legacySecureJwtKey);
|
||||
await _preferences.remove(storageLegacyAccessToken);
|
||||
await _preferences.setBool(storageAuthSessionActive, true);
|
||||
}
|
||||
|
||||
Future<void> clearJwt() async {
|
||||
await _storage.delete(_accessTokenKey);
|
||||
await _storage.delete(_legacySecureJwtKey);
|
||||
await _preferences.remove(storageLegacyAccessToken);
|
||||
await _preferences.remove(storageAuthSessionActive);
|
||||
}
|
||||
}
|
||||
|
||||
final secureIdentityStorageProvider = Provider<SecureIdentityStorage>(
|
||||
(ref) => SecureIdentityStorage(),
|
||||
(ref) => SecureIdentityStorage(StorageService.to.preferences),
|
||||
);
|
||||
|
||||
String? _normalized(String? value) {
|
||||
final normalized = value?.trim() ?? '';
|
||||
return normalized.isEmpty ? null : normalized;
|
||||
}
|
||||
@@ -8,6 +8,11 @@ const storageJinzhiPurityPresets = 'jinzhi_purity_presets';
|
||||
const storageJinzhiPriceColorMode = 'jinzhi_price_color_mode';
|
||||
const storageJinzhiMockLoggedIn = 'jinzhi_mock_logged_in';
|
||||
|
||||
// Previous builds could persist credentials in SharedPreferences. These keys
|
||||
// exist only for one-time migration into secure storage.
|
||||
const storageLegacyAccessToken = 'access_token';
|
||||
const storageAuthSessionActive = 'auth_session_active';
|
||||
|
||||
// 设备/广告标识。由各自的 SDK/ATT 流程写入,DeviceHeaderService 读出注入
|
||||
// header;未写入则为空、注入时被过滤。
|
||||
const storageDeviceToken = 'device_token'; // 推送 token
|
||||
|
||||
@@ -6,6 +6,8 @@ class StorageService {
|
||||
static StorageService? _instance;
|
||||
static StorageService get to => _instance!;
|
||||
|
||||
SharedPreferences get preferences => _prefs;
|
||||
|
||||
static Future<StorageService> get instance async {
|
||||
if (_instance != null) return _instance!;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -17,4 +19,6 @@ class StorageService {
|
||||
Future<void> setBool(String key, bool v) => _prefs.setBool(key, v);
|
||||
String? getString(String key) => _prefs.getString(key);
|
||||
Future<void> setString(String key, String v) => _prefs.setString(key, v);
|
||||
bool containsKey(String key) => _prefs.containsKey(key);
|
||||
Future<void> remove(String key) => _prefs.remove(key);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
/// 金值统一的网络图片入口。
|
||||
///
|
||||
/// 图片会自动使用 [CachedNetworkImage] 的磁盘缓存;业务页面不应直接创建
|
||||
/// `Image.network`,以保证加载和失败状态的视觉及缓存策略一致。
|
||||
class AppNetworkImage extends StatelessWidget {
|
||||
const AppNetworkImage({
|
||||
super.key,
|
||||
required this.imageUrl,
|
||||
this.width,
|
||||
this.height,
|
||||
this.fit = BoxFit.cover,
|
||||
this.alignment = Alignment.center,
|
||||
this.borderRadius,
|
||||
this.cacheKey,
|
||||
this.httpHeaders,
|
||||
this.placeholder,
|
||||
this.errorWidget,
|
||||
this.semanticLabel,
|
||||
});
|
||||
|
||||
final String imageUrl;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final BoxFit fit;
|
||||
final Alignment alignment;
|
||||
final BorderRadius? borderRadius;
|
||||
final String? cacheKey;
|
||||
final Map<String, String>? httpHeaders;
|
||||
final Widget? placeholder;
|
||||
final Widget? errorWidget;
|
||||
final String? semanticLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final child = imageUrl.trim().isEmpty
|
||||
? _ImageFallback(errorWidget: errorWidget)
|
||||
: CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
cacheKey: cacheKey,
|
||||
httpHeaders: httpHeaders,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
alignment: alignment,
|
||||
fadeInDuration: const Duration(milliseconds: 150),
|
||||
placeholder: (_, _) =>
|
||||
placeholder ?? const _ImageLoadingPlaceholder(),
|
||||
errorWidget: (_, _, _) => _ImageFallback(errorWidget: errorWidget),
|
||||
);
|
||||
|
||||
final clipped = borderRadius == null
|
||||
? child
|
||||
: ClipRRect(borderRadius: borderRadius!, child: child);
|
||||
if (semanticLabel == null) return clipped;
|
||||
return Semantics(image: true, label: semanticLabel, child: clipped);
|
||||
}
|
||||
}
|
||||
|
||||
class _ImageLoadingPlaceholder extends StatelessWidget {
|
||||
const _ImageLoadingPlaceholder();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = ShadTheme.of(context).colorScheme;
|
||||
return ColoredBox(
|
||||
color: colors.muted,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colors.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ImageFallback extends StatelessWidget {
|
||||
const _ImageFallback({required this.errorWidget});
|
||||
|
||||
final Widget? errorWidget;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (errorWidget != null) return errorWidget!;
|
||||
final colors = ShadTheme.of(context).colorScheme;
|
||||
return ColoredBox(
|
||||
color: colors.muted,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.image_not_supported_outlined,
|
||||
color: colors.mutedForeground,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:easy_refresh/easy_refresh.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
typedef RefreshAction = Future<void> Function();
|
||||
|
||||
/// Shared pull-to-refresh and load-more wrapper for future list features.
|
||||
class AppRefresh extends StatelessWidget {
|
||||
const AppRefresh({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.onRefresh,
|
||||
this.onLoad,
|
||||
this.controller,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final RefreshAction? onRefresh;
|
||||
final RefreshAction? onLoad;
|
||||
final EasyRefreshController? controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => EasyRefresh(
|
||||
controller: controller,
|
||||
onRefresh: onRefresh,
|
||||
onLoad: onLoad,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import 'app_state_card.dart';
|
||||
|
||||
/// Shared loading, failure and empty-state contract for feature pages.
|
||||
class AsyncStateView<T> extends StatelessWidget {
|
||||
const AsyncStateView({
|
||||
super.key,
|
||||
required this.state,
|
||||
required this.data,
|
||||
this.isEmpty,
|
||||
this.empty,
|
||||
this.loading,
|
||||
this.onRetry,
|
||||
this.errorTitle = '加载失败',
|
||||
this.errorSubtitle = '请检查网络后重试',
|
||||
this.emptyIcon = Icons.inbox_outlined,
|
||||
this.emptyTitle = '暂无内容',
|
||||
this.emptySubtitle = '暂时没有可展示的数据',
|
||||
});
|
||||
|
||||
final AsyncValue<T> state;
|
||||
final Widget Function(T value) data;
|
||||
final bool Function(T value)? isEmpty;
|
||||
final Widget Function()? empty;
|
||||
final Widget? loading;
|
||||
final VoidCallback? onRetry;
|
||||
final String errorTitle;
|
||||
final String errorSubtitle;
|
||||
final IconData emptyIcon;
|
||||
final String emptyTitle;
|
||||
final String emptySubtitle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return state.when(
|
||||
loading: () =>
|
||||
loading ?? const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) => Center(
|
||||
child: AppStateCard(
|
||||
icon: Icons.error_outline,
|
||||
title: errorTitle,
|
||||
subtitle: errorSubtitle,
|
||||
buttonLabel: onRetry == null ? null : '重试',
|
||||
onButtonPressed: onRetry,
|
||||
buttonStyle: AppStateButtonStyle.filled,
|
||||
),
|
||||
),
|
||||
data: (value) => isEmpty?.call(value) == true
|
||||
? empty?.call() ??
|
||||
Center(
|
||||
child: AppStateCard(
|
||||
icon: emptyIcon,
|
||||
title: emptyTitle,
|
||||
subtitle: emptySubtitle,
|
||||
),
|
||||
)
|
||||
: data(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:easy_refresh/easy_refresh.dart';
|
||||
|
||||
/// Applies one Chinese copy set to every pull-to-refresh/list-load control.
|
||||
void configureEasyRefreshChineseLocalization() {
|
||||
EasyRefresh.defaultHeaderBuilder = () => const ClassicHeader(
|
||||
dragText: '下拉刷新',
|
||||
armedText: '松开刷新',
|
||||
readyText: '刷新中...',
|
||||
processingText: '刷新中...',
|
||||
processedText: '刷新成功',
|
||||
noMoreText: '没有更多了',
|
||||
failedText: '刷新失败',
|
||||
messageText: '上次更新于 %T',
|
||||
);
|
||||
EasyRefresh.defaultFooterBuilder = () => const ClassicFooter(
|
||||
dragText: '上拉加载',
|
||||
armedText: '松开加载更多',
|
||||
readyText: '加载中...',
|
||||
processingText: '加载中...',
|
||||
processedText: '加载完成',
|
||||
noMoreText: '没有更多了',
|
||||
failedText: '加载失败',
|
||||
messageText: '上次更新于 %T',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../../common/services/device_header_service.dart';
|
||||
import '../../auth/data/auth_models.dart';
|
||||
|
||||
class FeedbackEmailService {
|
||||
const FeedbackEmailService._();
|
||||
|
||||
static const recipient = 'business@yqzhice.top';
|
||||
static const productName = '金值';
|
||||
|
||||
static Future<void> send(AuthState? authState) async {
|
||||
final email = await _buildEmail(authState);
|
||||
final uri = composeMailtoUri(
|
||||
recipient: email.recipient,
|
||||
subject: email.subject,
|
||||
body: email.body,
|
||||
);
|
||||
try {
|
||||
final launched = await launchUrl(
|
||||
uri,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
if (!launched) throw const FeedbackEmailUnavailableException();
|
||||
} catch (_) {
|
||||
throw const FeedbackEmailUnavailableException();
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
static Uri composeMailtoUri({
|
||||
required String recipient,
|
||||
required String subject,
|
||||
required String body,
|
||||
}) => Uri(
|
||||
scheme: 'mailto',
|
||||
path: recipient,
|
||||
query: _encodeQueryParameters({'subject': subject, 'body': body}),
|
||||
);
|
||||
|
||||
static String _encodeQueryParameters(
|
||||
Map<String, String> parameters,
|
||||
) => parameters.entries
|
||||
.map(
|
||||
(entry) =>
|
||||
'${Uri.encodeComponent(entry.key)}=${Uri.encodeComponent(entry.value)}',
|
||||
)
|
||||
.join('&');
|
||||
|
||||
static Future<_FeedbackEmail> _buildEmail(AuthState? authState) async {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
final device = await _loadDeviceInfo();
|
||||
try {
|
||||
await DeviceHeaderService.to.ensureInitialized();
|
||||
} catch (_) {}
|
||||
|
||||
final headers = DeviceHeaderService.to.commonHeaders();
|
||||
final loggedIn = authState is LoggedInAuthState ? authState : null;
|
||||
final version = '${packageInfo.version}+${packageInfo.buildNumber}';
|
||||
final diagnostics = [
|
||||
'App version: $version',
|
||||
'Channel: ${headers['User-Channel'] ?? ''}',
|
||||
'OS version: ${device.osVersion}',
|
||||
'Device manufacturer: ${device.manufacturer}',
|
||||
'Device model: ${device.model}',
|
||||
'Login type: ${loggedIn == null ? 'guest' : 'phone'}',
|
||||
'Phone: ${loggedIn?.user.phone ?? ''}',
|
||||
].join('\n');
|
||||
return _FeedbackEmail(
|
||||
recipient: recipient,
|
||||
subject:
|
||||
'$productName反馈[$version]-[${device.manufacturer}]-[${device.osVersion}]',
|
||||
body: [
|
||||
'$productName团队你好,',
|
||||
'请描述你的问题,可附上截图或视频:',
|
||||
'',
|
||||
'',
|
||||
'----',
|
||||
diagnostics,
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<_FeedbackDeviceInfo> _loadDeviceInfo() async {
|
||||
final plugin = DeviceInfoPlugin();
|
||||
try {
|
||||
if (kIsWeb) {
|
||||
final info = await plugin.webBrowserInfo;
|
||||
return _FeedbackDeviceInfo(
|
||||
osVersion: info.platform ?? 'web',
|
||||
manufacturer: info.browserName.name,
|
||||
model: info.userAgent ?? '',
|
||||
);
|
||||
}
|
||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||
final info = await plugin.androidInfo;
|
||||
return _FeedbackDeviceInfo(
|
||||
osVersion: info.version.release,
|
||||
manufacturer: info.manufacturer,
|
||||
model: info.model,
|
||||
);
|
||||
}
|
||||
if (defaultTargetPlatform == TargetPlatform.iOS) {
|
||||
final info = await plugin.iosInfo;
|
||||
return _FeedbackDeviceInfo(
|
||||
osVersion: info.systemVersion,
|
||||
manufacturer: 'Apple',
|
||||
model: info.utsname.machine,
|
||||
);
|
||||
}
|
||||
return const _FeedbackDeviceInfo(
|
||||
osVersion: '',
|
||||
manufacturer: '',
|
||||
model: '',
|
||||
);
|
||||
} catch (_) {
|
||||
return const _FeedbackDeviceInfo(
|
||||
osVersion: '',
|
||||
manufacturer: '',
|
||||
model: '',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FeedbackEmailUnavailableException implements Exception {
|
||||
const FeedbackEmailUnavailableException();
|
||||
}
|
||||
|
||||
class _FeedbackEmail {
|
||||
const _FeedbackEmail({
|
||||
required this.recipient,
|
||||
required this.subject,
|
||||
required this.body,
|
||||
});
|
||||
|
||||
final String recipient;
|
||||
final String subject;
|
||||
final String body;
|
||||
}
|
||||
|
||||
class _FeedbackDeviceInfo {
|
||||
const _FeedbackDeviceInfo({
|
||||
required this.osVersion,
|
||||
required this.manufacturer,
|
||||
required this.model,
|
||||
});
|
||||
|
||||
final String osVersion;
|
||||
final String manufacturer;
|
||||
final String model;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_remix/flutter_remix.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@@ -20,6 +19,7 @@ import '../../auth/application/auth_controller.dart';
|
||||
import '../../auth/data/auth_models.dart';
|
||||
import '../../profile/application/profile_settings_controller.dart';
|
||||
import '../../profile/data/profile_models.dart';
|
||||
import '../application/feedback_email_service.dart';
|
||||
|
||||
class SettingsPage extends HookConsumerWidget {
|
||||
const SettingsPage({super.key});
|
||||
@@ -31,8 +31,6 @@ class SettingsPage extends HookConsumerWidget {
|
||||
final profileSettings = ref.watch(profileSettingsControllerProvider);
|
||||
final authAsync = ref.watch(authProvider);
|
||||
final loggedIn = authAsync.valueOrNull is LoggedInAuthState;
|
||||
final contactDebugTapCount = useState(0);
|
||||
final contactLastTapAt = useRef<DateTime?>(null);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: cs.background,
|
||||
@@ -199,21 +197,12 @@ class SettingsPage extends HookConsumerWidget {
|
||||
_NavRow(
|
||||
icon: FlutterRemix.mail_line,
|
||||
label: '联系我们',
|
||||
trailing: 'business@hnsiderui.cn',
|
||||
onTap: () {
|
||||
final now = DateTime.now();
|
||||
final lastTapAt = contactLastTapAt.value;
|
||||
if (lastTapAt == null ||
|
||||
now.difference(lastTapAt) >
|
||||
const Duration(milliseconds: 500)) {
|
||||
contactDebugTapCount.value = 0;
|
||||
}
|
||||
contactLastTapAt.value = now;
|
||||
contactDebugTapCount.value += 1;
|
||||
if (contactDebugTapCount.value >= 10) {
|
||||
contactDebugTapCount.value = 0;
|
||||
contactLastTapAt.value = null;
|
||||
context.push(AppRoutes.debug);
|
||||
trailing: FeedbackEmailService.recipient,
|
||||
onTap: () async {
|
||||
try {
|
||||
await FeedbackEmailService.send(authAsync.valueOrNull);
|
||||
} catch (_) {
|
||||
if (context.mounted) _showFeedbackEmailFailDialog(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -337,13 +326,15 @@ class SettingsPage extends HookConsumerWidget {
|
||||
_ChoiceButton(
|
||||
label: PriceColorMode.redUpGreenDown.label,
|
||||
selected: current == PriceColorMode.redUpGreenDown,
|
||||
onTap: () => Navigator.of(ctx).pop(PriceColorMode.redUpGreenDown),
|
||||
onTap: () =>
|
||||
Navigator.of(ctx).pop(PriceColorMode.redUpGreenDown),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_ChoiceButton(
|
||||
label: PriceColorMode.greenUpRedDown.label,
|
||||
selected: current == PriceColorMode.greenUpRedDown,
|
||||
onTap: () => Navigator.of(ctx).pop(PriceColorMode.greenUpRedDown),
|
||||
onTap: () =>
|
||||
Navigator.of(ctx).pop(PriceColorMode.greenUpRedDown),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -351,9 +342,9 @@ class SettingsPage extends HookConsumerWidget {
|
||||
),
|
||||
);
|
||||
if (selected != null && context.mounted) {
|
||||
ref.read(profileSettingsControllerProvider.notifier).setPriceColorMode(
|
||||
selected,
|
||||
);
|
||||
ref
|
||||
.read(profileSettingsControllerProvider.notifier)
|
||||
.setPriceColorMode(selected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -720,6 +711,24 @@ class SettingsPage extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
void _showFeedbackEmailFailDialog(BuildContext context) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('无法发送反馈'),
|
||||
content: const Text(
|
||||
'请配置并登录系统邮箱,或使用其他邮箱发送邮件至 ${FeedbackEmailService.recipient}。',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _Section extends StatelessWidget {
|
||||
const _Section(this.title);
|
||||
|
||||
@@ -1081,9 +1090,7 @@ class _ChoiceButton extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? cs.primary : cs.secondary,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: selected ? cs.primary : cs.border,
|
||||
),
|
||||
border: Border.all(color: selected ? cs.primary : cs.border),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
@@ -1182,10 +1189,7 @@ class _ValueRow extends StatelessWidget {
|
||||
style: TextStyle(fontSize: 15, color: cs.foreground),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(fontSize: 13, color: cs.mutedForeground),
|
||||
),
|
||||
Text(value, style: TextStyle(fontSize: 13, color: cs.mutedForeground)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
+107
-3
@@ -155,6 +155,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.12.6"
|
||||
cached_network_image:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cached_network_image
|
||||
sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.1"
|
||||
cached_network_image_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cached_network_image_platform_interface
|
||||
sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.1"
|
||||
cached_network_image_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cached_network_image_web
|
||||
sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.1"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -315,6 +339,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
easy_refresh:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: easy_refresh
|
||||
sha256: "513a5dc82c044f8a9c9a3d600b3d5d3256070575bfd8761940533d8e209ba79b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.5.1"
|
||||
extended_image:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -384,6 +416,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.2"
|
||||
flutter_cache_manager:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_cache_manager
|
||||
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.1"
|
||||
flutter_dotenv:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -808,6 +848,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
octo_image:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: octo_image
|
||||
sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -840,6 +888,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_drawing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_drawing
|
||||
sha256: bbb1934c0cbb03091af082a6389ca2080345291ef07a5fa6d6e078ba8682f977
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
path_parsing:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -916,9 +972,9 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: f381964
|
||||
resolved-ref: f3819645c70c02b878c754a61ef9ebd3421f2127
|
||||
url: "https://github.com/maoios/platform_metadata.git"
|
||||
ref: main
|
||||
resolved-ref: bbe1aa0213d3f310f35be59829380ecf01fe9e96
|
||||
url: "https://gitea.neuronlabs.art/zhouyu/platform_metadata.git"
|
||||
source: git
|
||||
version: "0.0.2+2"
|
||||
plugin_platform_interface:
|
||||
@@ -1174,6 +1230,46 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
sqflite:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite
|
||||
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
sqflite_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_android
|
||||
sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2+2"
|
||||
sqflite_common:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_common
|
||||
sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.6"
|
||||
sqflite_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_darwin
|
||||
sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
sqflite_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_platform_interface
|
||||
sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1214,6 +1310,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: synchronized
|
||||
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.0"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -36,6 +36,9 @@ dependencies:
|
||||
google_fonts: ^6.2.1
|
||||
flutter_remix: ^0.0.3
|
||||
shimmer: ^3.0.0
|
||||
# 网络图片与磁盘缓存
|
||||
cached_network_image: ^3.4.1
|
||||
flutter_cache_manager: ^3.4.1
|
||||
flutter_markdown_plus: ^1.0.7
|
||||
video_player: ^2.10.0 # 启动页视频背景
|
||||
# 工具
|
||||
@@ -66,6 +69,7 @@ dependencies:
|
||||
fluttertoast: ^9.0.0
|
||||
shorebird_code_push: ^2.0.6
|
||||
flutter_html: ^3.0.0
|
||||
easy_refresh: ^3.5.1
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/features/settings/application/feedback_email_service.dart';
|
||||
|
||||
void main() {
|
||||
test('encodes feedback email subject and body as a mailto URI', () {
|
||||
final uri = FeedbackEmailService.composeMailtoUri(
|
||||
recipient: 'support@example.com',
|
||||
subject: '金值反馈',
|
||||
body: '问题描述\n第二行',
|
||||
);
|
||||
|
||||
expect(uri.scheme, 'mailto');
|
||||
expect(uri.path, 'support@example.com');
|
||||
expect(uri.queryParameters['subject'], '金值反馈');
|
||||
expect(uri.queryParameters['body'], '问题描述\n第二行');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/common/lifecycle/refresh_coordinator.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('refreshes active registrations when returning to foreground', (
|
||||
tester,
|
||||
) async {
|
||||
final coordinator = RefreshCoordinator();
|
||||
addTearDown(coordinator.dispose);
|
||||
var activeCalls = 0;
|
||||
var inactiveCalls = 0;
|
||||
coordinator.register(onRefresh: () => activeCalls++);
|
||||
coordinator.register(onRefresh: () => inactiveCalls++, active: false);
|
||||
|
||||
coordinator.didChangeAppLifecycleState(AppLifecycleState.resumed);
|
||||
await tester.pump();
|
||||
|
||||
expect(activeCalls, 1);
|
||||
expect(inactiveCalls, 0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/secure_identity_storage.dart';
|
||||
import 'package:jinzhi_flutter_app/common/storage/storage_keys.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
test(
|
||||
'migrates a legacy SharedPreferences token into secure storage',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
storageLegacyAccessToken: ' legacy ',
|
||||
});
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final secure = _MemorySecureStorage();
|
||||
final storage = SecureIdentityStorage(preferences, secureStorage: secure);
|
||||
|
||||
expect(await storage.restoreAccessToken(), 'legacy');
|
||||
expect(await secure.read('access_token'), 'legacy');
|
||||
expect(preferences.containsKey(storageLegacyAccessToken), isFalse);
|
||||
expect(preferences.getBool(storageAuthSessionActive), isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'migrates the previous secure jwt key when no current token exists',
|
||||
() async {
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
final secure = _MemorySecureStorage({'jwt_token': 'secure-legacy'});
|
||||
final storage = SecureIdentityStorage(preferences, secureStorage: secure);
|
||||
|
||||
expect(await storage.restoreAccessToken(), 'secure-legacy');
|
||||
expect(await secure.read('access_token'), 'secure-legacy');
|
||||
expect(await secure.read('jwt_token'), isNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _MemorySecureStorage implements SecureKeyValueStorage {
|
||||
_MemorySecureStorage([Map<String, String>? initial])
|
||||
: _values = {...?initial};
|
||||
|
||||
final Map<String, String> _values;
|
||||
|
||||
@override
|
||||
Future<void> delete(String key) async => _values.remove(key);
|
||||
|
||||
@override
|
||||
Future<String?> read(String key) async => _values[key];
|
||||
|
||||
@override
|
||||
Future<void> write(String key, String value) async {
|
||||
_values[key] = value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jinzhi_flutter_app/common/theme/jinzhi_theme.dart';
|
||||
import 'package:jinzhi_flutter_app/common/widgets/app_network_image.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('uses the standard fallback for an empty image URL', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
ShadApp(
|
||||
theme: ShadThemeData(colorScheme: jinzhiLightScheme),
|
||||
home: const Scaffold(body: AppNetworkImage(imageUrl: ' ')),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byIcon(Icons.image_not_supported_outlined), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('uses a supplied fallback for an empty image URL', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
ShadApp(
|
||||
theme: ShadThemeData(colorScheme: jinzhiLightScheme),
|
||||
home: const Scaffold(
|
||||
body: AppNetworkImage(
|
||||
imageUrl: '',
|
||||
errorWidget: Text('image unavailable'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('image unavailable'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user