diff --git a/lib/app/app.dart b/lib/app/app.dart index b0b3cdb..02c49df 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -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 厂商 diff --git a/lib/app/bootstrap.dart b/lib/app/bootstrap.dart index cba7b64..bd028e1 100644 --- a/lib/app/bootstrap.dart +++ b/lib/app/bootstrap.dart @@ -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 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 diff --git a/lib/common/lifecycle/refresh_coordinator.dart b/lib/common/lifecycle/refresh_coordinator.dart new file mode 100644 index 0000000..52ae7eb --- /dev/null +++ b/lib/common/lifecycle/refresh_coordinator.dart @@ -0,0 +1,84 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +typedef RefreshCallback = FutureOr 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 _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((ref) { + final coordinator = RefreshCoordinator(); + ref.onDispose(coordinator.dispose); + return coordinator; +}); diff --git a/lib/common/network/auth_interceptor.dart b/lib/common/network/auth_interceptor.dart index b180754..e6a6352 100644 --- a/lib/common/network/auth_interceptor.dart +++ b/lib/common/network/auth_interceptor.dart @@ -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 saveJwt(String token) => _defaultIdentityStorage.saveJwt(token); Future clearJwt() => _defaultIdentityStorage.clearJwt(); diff --git a/lib/common/router/app_router.dart b/lib/common/router/app_router.dart index d9f927a..446104a 100644 --- a/lib/common/router/app_router.dart +++ b/lib/common/router/app_router.dart @@ -54,22 +54,43 @@ final routerProvider = Provider((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(), + ), + ], ), ], ), diff --git a/lib/common/router/tab_shell.dart b/lib/common/router/tab_shell.dart index 8eb1f5c..520a0ba 100644 --- a/lib/common/router/tab_shell.dart +++ b/lib/common/router/tab_shell.dart @@ -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 { 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 { color: cs.background, child: NotificationListener( onNotification: _handleScrollNotification, - child: Stack(children: [Positioned.fill(child: widget.child)]), + child: widget.navigationShell, ), ), bottomNavigationBar: SafeArea( @@ -129,7 +130,7 @@ class _TabShellState extends State { ), 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 { : _outlineIcons[i], label: _labels[i], selected: i == _index, - onTap: () => context.go(_tabs[i]), + onTap: () => + widget.navigationShell.goBranch( + i, + initialLocation: i == _index, + ), ), ), ], diff --git a/lib/common/storage/secure_identity_storage.dart b/lib/common/storage/secure_identity_storage.dart index a14dd28..ccb98c8 100644 --- a/lib/common/storage/secure_identity_storage.dart +++ b/lib/common/storage/secure_identity_storage.dart @@ -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 read(String key); + Future write(String key, String value); + Future delete(String key); +} + +class FlutterSecureKeyValueStorage implements SecureKeyValueStorage { + const FlutterSecureKeyValueStorage(); + + static const _storage = FlutterSecureStorage(); + + @override + Future read(String key) => _storage.read(key: key); + @override + Future write(String key, String value) => + _storage.write(key: key, value: value); + @override + Future 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 saveJwt(String token) => - _storage.write(key: _jwtKey, value: token); - Future clearJwt() => _storage.delete(key: _jwtKey); - Future readJwt() => _storage.read(key: _jwtKey); + final SharedPreferences _preferences; + final SecureKeyValueStorage _storage; + + Future 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 readJwt() async { + final token = _normalized(await _storage.read(_accessTokenKey)); + if (token == null || _preferences.containsKey(storageLegacyAccessToken)) { + return restoreAccessToken(); + } + return token; + } + + Future 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 clearJwt() async { + await _storage.delete(_accessTokenKey); + await _storage.delete(_legacySecureJwtKey); + await _preferences.remove(storageLegacyAccessToken); + await _preferences.remove(storageAuthSessionActive); + } } final secureIdentityStorageProvider = Provider( - (ref) => SecureIdentityStorage(), + (ref) => SecureIdentityStorage(StorageService.to.preferences), ); + +String? _normalized(String? value) { + final normalized = value?.trim() ?? ''; + return normalized.isEmpty ? null : normalized; +} diff --git a/lib/common/storage/storage_keys.dart b/lib/common/storage/storage_keys.dart index 4f48447..afb9710 100644 --- a/lib/common/storage/storage_keys.dart +++ b/lib/common/storage/storage_keys.dart @@ -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 diff --git a/lib/common/storage/storage_service.dart b/lib/common/storage/storage_service.dart index c7390e5..5bc6a39 100644 --- a/lib/common/storage/storage_service.dart +++ b/lib/common/storage/storage_service.dart @@ -6,6 +6,8 @@ class StorageService { static StorageService? _instance; static StorageService get to => _instance!; + SharedPreferences get preferences => _prefs; + static Future get instance async { if (_instance != null) return _instance!; final prefs = await SharedPreferences.getInstance(); @@ -17,4 +19,6 @@ class StorageService { Future setBool(String key, bool v) => _prefs.setBool(key, v); String? getString(String key) => _prefs.getString(key); Future setString(String key, String v) => _prefs.setString(key, v); + bool containsKey(String key) => _prefs.containsKey(key); + Future remove(String key) => _prefs.remove(key); } diff --git a/lib/common/widgets/app_network_image.dart b/lib/common/widgets/app_network_image.dart new file mode 100644 index 0000000..eb09616 --- /dev/null +++ b/lib/common/widgets/app_network_image.dart @@ -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? 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, + ), + ), + ); + } +} diff --git a/lib/common/widgets/app_refresh.dart b/lib/common/widgets/app_refresh.dart new file mode 100644 index 0000000..38880a3 --- /dev/null +++ b/lib/common/widgets/app_refresh.dart @@ -0,0 +1,28 @@ +import 'package:easy_refresh/easy_refresh.dart'; +import 'package:flutter/widgets.dart'; + +typedef RefreshAction = Future 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, + ); +} diff --git a/lib/common/widgets/async_state_view.dart b/lib/common/widgets/async_state_view.dart new file mode 100644 index 0000000..02a57e5 --- /dev/null +++ b/lib/common/widgets/async_state_view.dart @@ -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 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 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), + ); + } +} diff --git a/lib/common/widgets/easy_refresh_localization.dart b/lib/common/widgets/easy_refresh_localization.dart new file mode 100644 index 0000000..0183af6 --- /dev/null +++ b/lib/common/widgets/easy_refresh_localization.dart @@ -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', + ); +} diff --git a/lib/features/settings/application/feedback_email_service.dart b/lib/features/settings/application/feedback_email_service.dart new file mode 100644 index 0000000..3afd72b --- /dev/null +++ b/lib/features/settings/application/feedback_email_service.dart @@ -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 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 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; +} diff --git a/lib/features/settings/presentation/settings_page.dart b/lib/features/settings/presentation/settings_page.dart index 95ed0f1..75de0e3 100644 --- a/lib/features/settings/presentation/settings_page.dart +++ b/lib/features/settings/presentation/settings_page.dart @@ -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(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( + 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)), ], ); } diff --git a/pubspec.lock b/pubspec.lock index 71cd272..d4f2710 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 82c1ec6..b75816a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/common/feedback_email_service_test.dart b/test/common/feedback_email_service_test.dart new file mode 100644 index 0000000..23bf982 --- /dev/null +++ b/test/common/feedback_email_service_test.dart @@ -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第二行'); + }); +} diff --git a/test/common/lifecycle/refresh_coordinator_test.dart b/test/common/lifecycle/refresh_coordinator_test.dart new file mode 100644 index 0000000..cff0de9 --- /dev/null +++ b/test/common/lifecycle/refresh_coordinator_test.dart @@ -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); + }); +} diff --git a/test/common/storage/secure_identity_storage_test.dart b/test/common/storage/secure_identity_storage_test.dart new file mode 100644 index 0000000..808f3f0 --- /dev/null +++ b/test/common/storage/secure_identity_storage_test.dart @@ -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? initial]) + : _values = {...?initial}; + + final Map _values; + + @override + Future delete(String key) async => _values.remove(key); + + @override + Future read(String key) async => _values[key]; + + @override + Future write(String key, String value) async { + _values[key] = value; + } +} diff --git a/test/common/widgets/app_network_image_test.dart b/test/common/widgets/app_network_image_test.dart new file mode 100644 index 0000000..c12a9be --- /dev/null +++ b/test/common/widgets/app_network_image_test.dart @@ -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); + }); +}