feat: align shared app foundations

This commit is contained in:
jingyun committed 2026-08-13 17:47:01 +08:00
1 parent bd6bb0ffce
commit c5653a3699
21 files changed
+894 -66

No files matched your search

+4
View File
@@ -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 厂商
+6
View File
@@ -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;
});
+4 -1
View File
@@ -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();
+35 -14
View File
@@ -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(),
),
],
),
],
),
+14 -9
View File
@@ -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;
}
+5
View File
@@ -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
+4
View File
@@ -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);
}
+104
View File
@@ -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,
),
),
);
}
}
+28
View File
@@ -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,
);
}
+62
View File
@@ -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
View File
@@ -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:
+4
View File
@@ -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);
});
}