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)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user