Author SHA1 Message Date
jingyun 50a1ac0af7 fix:报错处理 2026-08-14 09:39:11 +08:00
jingyun c5653a3699 feat: align shared app foundations 2026-08-13 17:47:01 +08:00
jingyun bd6bb0ffce fix:优化和对齐版本 2026-08-13 17:17:52 +08:00
171 changed files with 2034 additions and 11167 deletions

No files matched your search

+7
View File
@@ -45,3 +45,10 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
/android/.kotlin/
# Release signing material and packaged runtime secrets are restored locally
# from the team's secure storage; never commit them.
/android/key.properties
/android/app/files/*.jks
/assets/env/.env
+1 -1
View File
@@ -1,6 +1,6 @@
.PHONY: runner run test lint apk ipa build-web
WEB_API_URL ?= https://jinzhi.api.yqzhice.top
WEB_API_URL ?= https://jinzhi.api.local
WEB_BASE_HREF ?= /flutter/
GITVERSION := $(shell git describe --tags --always --dirty 2>/dev/null || git rev-parse --short HEAD)
COMMON_DART_DEFINES := --dart-define=GITVERSION=$(GITVERSION)
+2 -2
View File
@@ -23,7 +23,7 @@ fun requireSigningProperty(name: String): String {
}
android {
namespace = "com.jinzhi.butler"
namespace = "com.jinzhi.app"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
@@ -47,7 +47,7 @@ android {
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.jinzhi.butler"
applicationId = "com.jinzhi.app"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
Binary file not shown.
+6 -6
View File
@@ -1,7 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Debug-only local API support. Release inherits false from main. -->
<application
android:usesCleartextTraffic="true"
tools:replace="android:usesCleartextTraffic" />
</manifest>
+1 -5
View File
@@ -1,9 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<!-- <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />-->
<!-- <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />-->
@@ -13,7 +9,7 @@
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:usesCleartextTraffic="true">
android:usesCleartextTraffic="false">
<activity
android:name=".MainActivity"
android:exported="true"
@@ -1,4 +1,4 @@
package com.jinzhi.butler
package com.jinzhi.app
import io.flutter.embedding.android.FlutterActivity
+6
View File
@@ -2,6 +2,12 @@ allprojects {
repositories {
google()
mavenCentral()
// android_id on the Git main branch uses EasyProtector 1.1.2. Its
// original JCenter publication is retired; Appodeal maintains it.
maven("https://maven.aliyun.com/repository/public")
maven("https://maven.aliyun.com/repository/gradle-plugin")
maven("https://maven.aliyun.com/repository/google")
maven("https://maven.aliyun.com/repository/jcenter")
}
}
-4
View File
@@ -1,4 +0,0 @@
storePassword=jinzhi123
keyPassword=jinzhi123
keyAlias=jinzhi
storeFile=files/jinzhi.jks
-6
View File
@@ -1,6 +0,0 @@
# .env file
# UMENG keys for Muzhi environment (replace with actual keys later)
UMENG_ANDROID_APP_KEY=6a27b1f46f259537c7b616be
UMENG_IOS_APP_KEY=6a27b24e6f259537c7b617a8
UMENG_ANDROID_PUSH_SECRET=xxx
# UMENG_CHANNEL is usually set via build flavors or defaults, but can be here if needed.
-20
View File
@@ -1,20 +0,0 @@
应用名称:
金值
iOS平台
Bundle ID
公钥
证书SHA-1指纹
Android
包名:
com.jinzhi.butler
MD5:
bf875d2980f09f3acca3614b10aa0463
公钥(16进制)
a61ce1268140ae32cd7aeb43dfb2324a1bb04aba5c7cd5879e6f3b3e052f926ef80b9eccfc53b057a6a01d7dadf573ac19bf4bcfa9c4296fae20a0e1f4f598685321e4b3767bbc3a98d6afd056a522194528c3df1fed3872c697c49bc40fdc963071018ec644233e19e78bae9d3b5e5925e925322c5211f12fb079aa19464abd44bb782d81a30cc55c1b090f691d18aeb73beb26a710f45bb31a605ccf6519fe17e03130666e787bd2de0ceb8fd4bb66f2fd706af34db7840a504da090bb37f50e06153a84c3215ab1e6cbbb225b681baa84d90f97f1e245664d3fb6a1072506f4ba0548a4c28d84b239ed8500b0eb914bd3585565658e964317b48c0d84791b
签名证书SHA-1指纹
70 A8 9B 61 BE 5C 51 0A B8 42 78 2A FE 66 3A 4D 55 B9 10 4F
-8
View File
@@ -39,13 +39,5 @@ end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
'$(inherited)',
'PERMISSION_CAMERA=1',
'PERMISSION_PHOTOS=1',
]
end
end
end
+1 -40
View File
@@ -11,31 +11,16 @@ PODS:
- FlutterMacOS
- fluttertoast (0.0.2):
- Flutter
- gal (1.0.0):
- Flutter
- FlutterMacOS
- image_picker_ios (0.0.1):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- permission_handler_apple (9.4.8):
- Flutter
- photo_manager (3.9.0):
- Flutter
- FlutterMacOS
- platform_metadata (0.0.1):
- Flutter
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- url_launcher_ios (0.0.1):
- Flutter
- video_player_avfoundation (0.0.1):
@@ -49,16 +34,10 @@ DEPENDENCIES:
- flutter_paid (from `.symlinks/plugins/flutter_paid/ios`)
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
- gal (from `.symlinks/plugins/gal/darwin`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- photo_manager (from `.symlinks/plugins/photo_manager/darwin`)
- platform_metadata (from `.symlinks/plugins/platform_metadata/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
@@ -75,26 +54,14 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
fluttertoast:
:path: ".symlinks/plugins/fluttertoast/ios"
gal:
:path: ".symlinks/plugins/gal/darwin"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
photo_manager:
:path: ".symlinks/plugins/photo_manager/darwin"
platform_metadata:
:path: ".symlinks/plugins/platform_metadata/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
video_player_avfoundation:
@@ -107,19 +74,13 @@ SPEC CHECKSUMS:
flutter_paid: 6055d15f439071fefb849e3cebc279915ed70db5
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
gal: baecd024ebfd13c441269ca7404792a7152fde89
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
permission_handler_apple: 92d754bbaa7361d436db2d6c3c1c2a0fdcec462e
photo_manager: 25fd77df14f4f0ba5ef99e2c61814dde77e2bceb
platform_metadata: a4d1e9569219bb2dcbb54510554611df032ea023
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a
PODFILE CHECKSUM: b6c30c22d7ac3adf21bf40601823fcf685f5b3d6
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
COCOAPODS: 1.16.2
+4 -18
View File
@@ -198,7 +198,6 @@
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
38380B0F0A71332BD02F1E6B /* [CP] Embed Pods Frameworks */,
EA4FE8E3948A035FEE08B35D /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -300,10 +299,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
@@ -362,23 +365,6 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
EA4FE8E3948A035FEE08B35D /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
-6
View File
@@ -56,11 +56,5 @@
<true/>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>NSCameraUsageDescription</key>
<string>用于为贵金属持仓添加发票、证书及实物照片。</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>用于为贵金属持仓添加发票、证书及实物照片。</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>用于为贵金属持仓添加发票、证书及实物照片。</string>
</dict>
</plist>
+10 -16
View File
@@ -1,34 +1,29 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:pull_to_refresh/pull_to_refresh.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';
import '../common/widgets/smart_refresh.dart';
class JinzhiApp extends ConsumerWidget {
const JinzhiApp({super.key});
@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);
final textTheme = ShadTextTheme();
return RefreshConfiguration(
springDescription: const SpringDescription(
mass: 1,
stiffness: 364.718677686,
damping: 35.2,
),
headerBuilder: () => const MaterialClassicHeader(),
footerBuilder: () => jinzhiClassicFooter,
hideFooterWhenNotFull: true,
headerTriggerDistance: 60,
maxOverScrollExtent: 100,
child: ShadApp.router(
// DM Sans 作主字体(拉丁/数字),中文走系统级联(iOS 苹方 / Android 厂商
// 中文字体)——主流做法,不显式点名、不内置字体。
final dmSansFamily = GoogleFonts.dmSans().fontFamily;
final textTheme = ShadTextTheme(family: dmSansFamily);
return ShadApp.router(
title: '金值',
themeMode: mode,
theme: ShadThemeData(
@@ -44,7 +39,6 @@ class JinzhiApp extends ConsumerWidget {
radius: BorderRadius.circular(Spacing.radiusBase),
),
routerConfig: router,
),
);
}
}
+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
+1 -1
View File
@@ -4,7 +4,7 @@ class AppConfig {
factory AppConfig.fromEnvironment() {
const url = String.fromEnvironment(
'JINZHI_API_BASE_URL',
defaultValue: 'https://jinzhi.api.yqzhice.top',
defaultValue: 'https://jinzhi.api.local',
);
return const AppConfig(apiBaseUrl: url);
}
+1 -1
View File
@@ -3,7 +3,7 @@ class AppH5Urls {
static const baseUrl = String.fromEnvironment(
'JINZHI_H5_BASE_URL',
defaultValue: 'https://jinzhi.h5.yqzhice.top',
defaultValue: 'https://jinzhi.h5.local',
);
static String get privacyUrl => '$baseUrl/beginner/privacyPolicy.html';
@@ -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;
});
+14 -8
View File
@@ -1,16 +1,22 @@
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../services/device_header_service.dart';
import '../storage/secure_identity_storage.dart';
import '../storage/storage_service.dart';
const _kJwtKey = 'jwt_token';
const _storage = FlutterSecureStorage();
final _defaultIdentityStorage = SecureIdentityStorage(
StorageService.to.preferences,
);
Future<void> saveJwt(String token) =>
_storage.write(key: _kJwtKey, value: token);
Future<void> clearJwt() => _storage.delete(key: _kJwtKey);
Future<String?> loadJwt() => _storage.read(key: _kJwtKey);
Future<void> saveJwt(String token) => _defaultIdentityStorage.saveJwt(token);
Future<void> clearJwt() => _defaultIdentityStorage.clearJwt();
Future<String?> loadJwt() => _defaultIdentityStorage.readJwt();
class AuthInterceptor extends Interceptor {
AuthInterceptor(this._identityStorage);
final SecureIdentityStorage _identityStorage;
@override
Future<void> onRequest(
RequestOptions options,
@@ -19,7 +25,7 @@ class AuthInterceptor extends Interceptor {
await DeviceHeaderService.to.ensureInitialized();
options.headers.addAll(DeviceHeaderService.to.commonHeaders());
options.headers.addAll(DeviceHeaderService.to.platformIdentityHeaders());
final jwt = await loadJwt();
final jwt = await _identityStorage.readJwt();
if (jwt != null && jwt.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $jwt';
}
+10 -1
View File
@@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../config/app_config.dart';
import '../storage/secure_identity_storage.dart';
import 'auth_interceptor.dart';
import 'kratos_error_interceptor.dart';
@@ -12,12 +13,20 @@ part 'dio_provider.g.dart';
const _protobufContentType = 'application/x-protobuf';
/// Per-request override for native protobuf-over-HTTP endpoints.
Options protobufOptions() => Options(
responseType: ResponseType.bytes,
contentType: _protobufContentType,
headers: const {'Accept': _protobufContentType},
);
@Riverpod(keepAlive: true)
AppConfig appConfig(Ref ref) => AppConfig.fromEnvironment();
@Riverpod(keepAlive: true)
Dio dio(Ref ref) {
final config = ref.watch(appConfigProvider);
final identityStorage = ref.watch(secureIdentityStorageProvider);
final d = Dio(
BaseOptions(
baseUrl: config.apiBaseUrl,
@@ -31,7 +40,7 @@ Dio dio(Ref ref) {
},
),
);
d.interceptors.add(AuthInterceptor());
d.interceptors.add(AuthInterceptor(identityStorage));
d.interceptors.add(KratosErrorInterceptor());
if (!kReleaseMode) {
d.interceptors.add(LogInterceptor(requestBody: false, responseBody: false));
+9
View File
@@ -0,0 +1,9 @@
import 'package:flutter/foundation.dart';
/// Android requires explicit privacy consent before collecting device
/// identifiers. iOS follows its existing startup flow instead.
bool requiresAndroidPrivacyConsent({
required bool isWeb,
required TargetPlatform platform,
required bool privacyAgreed,
}) => !isWeb && platform == TargetPlatform.android && !privacyAgreed;
+24 -159
View File
@@ -2,10 +2,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../features/assets/presentation/asset_add_page.dart';
import '../../features/assets/presentation/asset_edit_page.dart';
import '../../features/assets/presentation/assets_page.dart';
import '../../features/assets/presentation/holding_detail_page.dart';
import '../../features/auth/presentation/delete_account_page.dart';
import '../../features/auth/presentation/login_page.dart';
import '../../features/debug/presentation/debug_info_page.dart';
@@ -14,11 +11,8 @@ import '../../features/debug/presentation/debug_shorebird_page.dart';
import '../../features/debug/presentation/fancy_dio_inspector_page.dart';
import '../../features/debug/presentation/typography_test_page.dart';
import '../../features/market/presentation/market_page.dart';
import '../../features/market/presentation/market_kline_page.dart';
import '../../features/news/presentation/news_page.dart';
import '../../features/news/presentation/news_detail_page.dart';
import '../../features/profile/presentation/profile_page.dart';
import '../../features/profile/presentation/profile_action_pages.dart';
import '../../features/settings/presentation/settings_page.dart';
import '../../features/settings/presentation/privacy_consent_page.dart';
import '../services/device_header_service.dart';
@@ -26,25 +20,9 @@ import 'tab_shell.dart';
abstract final class AppRoutes {
static const assets = '/assets';
static const assetAdd = '/assets/add';
static const assetEdit = '/assets/edit';
static const market = '/market';
static const marketKline = '/market/kline';
static const news = '/news';
static const newsDetail = '/news/detail';
static const profile = '/profile';
static const profileInvite = '/profile/invite';
static const profileShare = '/profile/share';
static const profileLocalBackup = '/profile/local-backup';
static const profileCloudBackup = '/profile/cloud-backup';
static const profileWidget = '/profile/widget';
static const profilePurity = '/profile/purity';
static const profileRecycle = '/profile/recycle';
static const profilePriceColor = '/profile/price-color';
static const profileCurrency = '/profile/currency';
static const profileDisclaimer = '/profile/disclaimer';
static const profileAbout = '/profile/about';
static const assetDetail = '/assets/detail';
static const login = '/login';
static const settings = '/settings';
static const deleteAccount = '/delete-account';
@@ -76,106 +54,45 @@ final routerProvider = Provider<GoRouter>((ref) {
return null;
},
routes: [
ShellRoute(
builder: (context, state, child) =>
TabShell(location: state.matchedLocation, child: child),
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) => TabShell(
location: state.matchedLocation,
navigationShell: navigationShell,
),
branches: [
StatefulShellBranch(
routes: [
GoRoute(
path: AppRoutes.assets,
builder: (ctx, s) => const AssetsPage(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: AppRoutes.market,
builder: (ctx, s) => const MarketPage(),
),
GoRoute(path: AppRoutes.news, builder: (ctx, s) => const NewsPage()),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: AppRoutes.news,
builder: (ctx, s) => const NewsPage(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: AppRoutes.profile,
builder: (ctx, s) => const ProfilePage(),
),
],
),
GoRoute(
path: AppRoutes.assetDetail,
builder: (ctx, s) => HoldingDetailPage(id: s.uri.queryParameters['id']),
),
GoRoute(
path: AppRoutes.assetAdd,
pageBuilder: (ctx, s) =>
_rightToLeftPage(state: s, child: const AssetAddPage()),
),
GoRoute(
path: AppRoutes.assetEdit,
pageBuilder: (ctx, s) => _rightToLeftPage(
state: s,
child: AssetEditPage(id: s.uri.queryParameters['id'] ?? ''),
),
),
GoRoute(
path: AppRoutes.marketKline,
builder: (ctx, s) => MarketKlinePage(
metalId: s.uri.queryParameters['metal'],
periodId: s.uri.queryParameters['period'],
),
),
GoRoute(
path: AppRoutes.newsDetail,
builder: (ctx, s) => NewsDetailPage(id: s.uri.queryParameters['id']),
),
GoRoute(
path: AppRoutes.profileInvite,
pageBuilder: (ctx, s) =>
_bottomUpPage(state: s, child: const InviteRewardPage()),
),
GoRoute(
path: AppRoutes.profileShare,
pageBuilder: (ctx, s) =>
_bottomUpPage(state: s, child: const ShareCardPage()),
),
GoRoute(
path: AppRoutes.profileLocalBackup,
pageBuilder: (ctx, s) =>
_bottomUpPage(state: s, child: const LocalBackupPage()),
),
GoRoute(
path: AppRoutes.profileCloudBackup,
pageBuilder: (ctx, s) =>
_bottomUpPage(state: s, child: const CloudBackupPage()),
),
GoRoute(
path: AppRoutes.profileWidget,
pageBuilder: (ctx, s) =>
_bottomUpPage(state: s, child: const WidgetSettingsPage()),
),
GoRoute(
path: AppRoutes.profilePurity,
pageBuilder: (ctx, s) =>
_bottomUpPage(state: s, child: const PuritySettingsPage()),
),
GoRoute(
path: AppRoutes.profileRecycle,
pageBuilder: (ctx, s) =>
_bottomUpPage(state: s, child: const RecycleDiscountPage()),
),
GoRoute(
path: AppRoutes.profilePriceColor,
pageBuilder: (ctx, s) =>
_bottomUpPage(state: s, child: const PriceColorPage()),
),
GoRoute(
path: AppRoutes.profileCurrency,
pageBuilder: (ctx, s) =>
_bottomUpPage(state: s, child: const CurrencyPage()),
),
GoRoute(
path: AppRoutes.profileDisclaimer,
pageBuilder: (ctx, s) =>
_rightToLeftPage(state: s, child: const DisclaimerPage()),
),
GoRoute(
path: AppRoutes.profileAbout,
pageBuilder: (ctx, s) =>
_bottomUpPage(state: s, child: const AboutPage()),
],
),
GoRoute(path: AppRoutes.debug, builder: (_, s) => const DebugPage()),
GoRoute(
@@ -217,55 +134,3 @@ final routerProvider = Provider<GoRouter>((ref) {
],
);
});
CustomTransitionPage<T> _bottomUpPage<T>({
required GoRouterState state,
required Widget child,
}) {
return CustomTransitionPage<T>(
key: state.pageKey,
transitionDuration: const Duration(milliseconds: 260),
reverseTransitionDuration: const Duration(milliseconds: 220),
child: child,
transitionsBuilder: (context, animation, secondaryAnimation, pageChild) {
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.08),
end: Offset.zero,
).animate(curved),
child: FadeTransition(opacity: curved, child: pageChild),
);
},
);
}
CustomTransitionPage<T> _rightToLeftPage<T>({
required GoRouterState state,
required Widget child,
}) {
return CustomTransitionPage<T>(
key: state.pageKey,
transitionDuration: const Duration(milliseconds: 260),
reverseTransitionDuration: const Duration(milliseconds: 220),
child: child,
transitionsBuilder: (context, animation, secondaryAnimation, pageChild) {
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).animate(curved),
child: FadeTransition(opacity: curved, child: pageChild),
);
},
);
}
+66 -31
View File
@@ -1,13 +1,20 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../theme/app_icons.dart';
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
@@ -19,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) {
@@ -39,6 +43,11 @@ class _TabShellState extends State<TabShell> {
final canPop = GoRouter.of(context).canPop();
return Scaffold(
backgroundColor: cs.background,
resizeToAvoidBottomInset: false,
body: Stack(
children: [
Scaffold(
backgroundColor: cs.background,
resizeToAvoidBottomInset: false,
appBar: AppBar(
@@ -96,27 +105,44 @@ 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: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: cs.border, width: 1)),
),
child: SafeArea(
bottomNavigationBar: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
DecoratedBox(
decoration: BoxDecoration(
color: cs.background,
border: Border(
top: BorderSide(color: cs.border, width: 1),
),
),
child: SizedBox(
height: 62,
// 平板:tab 行限宽 600 居中(demo .nav .tabs)
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: Adaptive.tabsMaxWidth,
),
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 ? _filledIcons[i] : _outlineIcons[i],
icon: i == _index
? _filledIcons[i]
: _outlineIcons[i],
label: _labels[i],
selected: i == _index,
onTap: () => context.go(_tabs[i]),
onTap: () =>
widget.navigationShell.goBranch(
i,
initialLocation: i == _index,
),
),
),
],
@@ -124,6 +150,29 @@ class _TabShellState extends State<TabShell> {
),
),
),
),
],
),
),
),
if (!kReleaseMode)
Positioned(
right: 16,
bottom: 88,
child: FilledButton(
onPressed: () => context.push(AppRoutes.fancyDioInspector),
style: FilledButton.styleFrom(
backgroundColor: cs.primary,
foregroundColor: cs.primaryForeground,
minimumSize: const Size(60, 60),
shape: const CircleBorder(),
padding: EdgeInsets.zero,
),
child: const Text('请求'),
),
),
],
),
);
}
@@ -155,26 +204,12 @@ class _TabButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
final color = selected ? cs.primary : cs.mutedForeground;
final color = selected ? cs.foreground : cs.mutedForeground;
return InkWell(
onTap: onTap,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// if (selected)
// Container(
// width: 22,
// height: 2.5,
// margin: const EdgeInsets.only(bottom: 6),
// decoration: BoxDecoration(
// color: cs.primary,
// borderRadius: const BorderRadius.vertical(
// bottom: Radius.circular(3),
// ),
// ),
// )
// else
// const SizedBox(height: 8.5),
Icon(icon, size: 22, color: color),
const SizedBox(height: 4),
Text(
+12 -24
View File
@@ -3,7 +3,7 @@ import 'dart:io' show Platform;
import 'package:android_id/android_id.dart';
import 'package:android_oaid/android_oaid.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb;
import 'package:flutter_paid/flutter_paid.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:package_info_plus/package_info_plus.dart';
@@ -12,6 +12,7 @@ import 'package:uuid/uuid.dart';
import '../storage/storage_keys.dart';
import '../storage/storage_service.dart';
import '../privacy/privacy_gate.dart';
/// 请求公共 + 平台身份 header 来源(完全对齐研值契约)。
/// AuthInterceptor 每个请求注入 commonHeaders() + platformIdentityHeaders(),
@@ -43,8 +44,6 @@ class DeviceHeaderService {
String _androidOaid = '';
String _androidImei = '';
String _androidMac = '';
IosDeviceInfo? iosDeviceInfo;
AndroidDeviceInfo? androidDeviceInfo;
Future<void>? _baseInitialization;
Future<void>? _identityInitialization;
@@ -54,9 +53,11 @@ class DeviceHeaderService {
// Align with tradingagents-flutter-app: Android only initializes device
// information after the user has accepted the privacy policy.
if (!kIsWeb &&
Platform.isAndroid &&
!StorageService.to.getBool(storagePrivacyAgreed)) {
if (requiresAndroidPrivacyConsent(
isWeb: kIsWeb,
platform: defaultTargetPlatform,
privacyAgreed: StorageService.to.getBool(storagePrivacyAgreed),
)) {
return;
}
@@ -122,23 +123,9 @@ class DeviceHeaderService {
_iosIdfv = await _resolveIdfv();
await _collectIosPaidIfMissing();
}
await getDeviceInfo();
} catch (_) {}
}
Future<void> getDeviceInfo() async {
try {
final deviceInfoPlugin = DeviceInfoPlugin();
if (Platform.isIOS) {
iosDeviceInfo = await deviceInfoPlugin.iosInfo;
} else if (Platform.isAndroid) {
androidDeviceInfo = await deviceInfoPlugin.androidInfo;
}
} catch (e) {
print('$e');
}
}
Future<void> _collectAndroidIdIfAgreed() async {
if (_androidId.isNotEmpty) return;
if (!StorageService.to.getBool(storagePrivacyAgreed)) return; // 隐私同意后才采集
@@ -200,10 +187,11 @@ class DeviceHeaderService {
/// 是否需要首启隐私合规确认。**仅 Android**(合规要求:同意前不得采集设备
/// 标识)。由 go_router redirect 调用,未同意则强制跳隐私页。
bool get privacyConsentRequired =>
!kIsWeb &&
Platform.isAndroid &&
!StorageService.to.getBool(storagePrivacyAgreed);
bool get privacyConsentRequired => requiresAndroidPrivacyConsent(
isWeb: kIsWeb,
platform: defaultTargetPlatform,
privacyAgreed: StorageService.to.getBool(storagePrivacyAgreed),
);
/// iOS 首次启动页:仅在首次安装后展示一次。
bool get iosStartupRequired =>
@@ -0,0 +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';
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(
this._preferences, {
SecureKeyValueStorage secureStorage = const FlutterSecureKeyValueStorage(),
}) : _storage = secureStorage;
static const _accessTokenKey = 'access_token';
static const _legacySecureJwtKey = 'jwt_token';
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(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);
}
-20
View File
@@ -1,20 +0,0 @@
import '../../features/assets/data/asset_models.dart';
class UploadRepository {
const UploadRepository();
Future<UploadResult> uploadAssetImage(AssetImage image) async {
// TODO: Connect to upload backend.
return UploadResult(
remoteUrl: image.remoteUrl,
thumbnailUrl: image.thumbnailUrl,
);
}
}
class UploadResult {
const UploadResult({this.remoteUrl, this.thumbnailUrl});
final String? remoteUrl;
final String? thumbnailUrl;
}
-13
View File
@@ -1,13 +0,0 @@
import '../../features/assets/data/asset_models.dart';
import 'upload_repository.dart';
class UploadService {
const UploadService({required this.repository});
final UploadRepository repository;
Future<UploadResult> uploadAssetImage(AssetImage image) async {
// Placeholder: future implementation will upload the file and return URLs.
return repository.uploadAssetImage(image);
}
}
-119
View File
@@ -1,119 +0,0 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:gal/gal.dart';
import 'package:http/http.dart' as http;
import 'package:jinzhi_flutter_app/common/services/device_header_service.dart';
import 'package:permission_handler/permission_handler.dart';
class ImageUtil {
static const String _albumName = 'jinzhi';
static Stream<FileResponse> downloadImage(String url, String key) {
return DefaultCacheManager().getFileStream(
url,
key: key,
withProgress: true,
);
}
static Future<String> downloadImageInFile(String url, String key) async {
final file = await DefaultCacheManager().getSingleFile(url, key: key);
return file.path;
}
static Future<bool> requestForImagePermission() async {
if (Platform.isIOS) {
PermissionStatus photoStatus = await Permission.photosAddOnly.status;
if (photoStatus != PermissionStatus.granted) {
photoStatus = await Permission.photosAddOnly.request();
if (photoStatus != PermissionStatus.granted) {
return false;
}
}
} else if (Platform.isAndroid) {
int? sdkInt = DeviceHeaderService.to.androidDeviceInfo?.version.sdkInt;
if (sdkInt != null && sdkInt >= 33) {
// ✅ Android 13+ 不需要读权限,直接返回 true
// gal 插件内部通过 MediaStore 写入
return true;
} else {
// ✅ Android 10-12 仍可能需要 storage 权限
final status = await Permission.storage.request();
return status.isGranted;
}
} else {
return false;
}
return true;
}
static Future<void> saveImage(FileInfo fileInfo) async {
await Gal.putImage(fileInfo.file.path, album: _albumName);
}
static Future<void> saveImageData(ByteData byteData) async {
Uint8List imageBytes = byteData.buffer.asUint8List();
await Gal.putImageBytes(
imageBytes,
name: 'XWallpaper_share',
album: _albumName,
);
}
static Future<void> saveImageUint8List(Uint8List imageBytes) async {
await Gal.putImageBytes(
imageBytes,
name: 'XWallpaper_share',
album: _albumName,
);
}
static Future<void> saveUIImageData(ui.Image image) async {
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
Uint8List imageBytes = byteData!.buffer.asUint8List();
await Gal.putImageBytes(
imageBytes,
name: 'XWallpaper_share',
album: _albumName,
);
}
static Future<Uint8List> createTransparentImage(double aspectRatio) async {
double minSize = 100;
double width = minSize * aspectRatio;
double height = minSize;
final recorder = PictureRecorder();
final canvas = Canvas(
recorder,
Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()),
);
final paint = Paint()..color = Colors.transparent;
canvas.drawRect(
Rect.fromLTWH(0.0, 0.0, width.toDouble(), height.toDouble()),
paint,
);
final picture = recorder.endRecording();
final img = await picture.toImage(width.toInt(), height.toInt());
final pngBytes = await img.toByteData(format: ImageByteFormat.png);
return pngBytes!.buffer.asUint8List();
}
// 下载图片并返回 Uint8List(简单实现)
static Future<Uint8List?> fetchThumbBytes(String? url) async {
if (url == null || url.isEmpty) return null;
try {
final resp = await http.get(Uri.parse(url));
if (resp.statusCode == 200) return resp.bodyBytes;
} catch (_) {}
return null;
}
}
-74
View File
@@ -1,74 +0,0 @@
// ignore_for_file: use_build_context_synchronously
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:share_plus/share_plus.dart';
ui.Rect? shareOriginForContext(BuildContext context) {
final renderObject = context.findRenderObject();
if (renderObject is! RenderBox || !renderObject.hasSize) {
return null;
}
return renderObject.localToGlobal(Offset.zero) & renderObject.size;
}
Future<Uint8List> captureWidgetPng(
GlobalKey key, {
double pixelRatio = 3.0,
}) async {
await WidgetsBinding.instance.endOfFrame;
final context = key.currentContext;
if (context == null) {
throw StateError('share target is not mounted');
}
final renderObject = context.findRenderObject();
if (renderObject is! RenderRepaintBoundary) {
throw StateError('share target is not a repaint boundary');
}
final image = await renderObject.toImage(pixelRatio: pixelRatio);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
if (byteData == null) {
throw StateError('failed to convert share image');
}
return byteData.buffer.asUint8List();
}
Future<void> sharePngFromBoundary(
GlobalKey key, {
required String fileName,
String? text,
String? subject,
ui.Rect? sharePositionOrigin,
double pixelRatio = 3.0,
}) async {
final bytes = await captureWidgetPng(key, pixelRatio: pixelRatio);
final xFile = XFile.fromData(bytes, mimeType: 'image/png', name: fileName);
await SharePlus.instance.share(
ShareParams(
files: [xFile],
text: text,
subject: subject,
sharePositionOrigin: sharePositionOrigin,
),
);
}
Future<void> sharePlainText({
required String text,
String? subject,
ui.Rect? sharePositionOrigin,
}) async {
await SharePlus.instance.share(
ShareParams(
text: text,
subject: subject,
sharePositionOrigin: sharePositionOrigin,
),
);
}
+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',
);
}
-292
View File
@@ -1,292 +0,0 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
class PrototypeFrame extends StatelessWidget {
const PrototypeFrame({
super.key,
required this.child,
this.top = 0,
this.bottom = 24,
});
final Widget child;
final double top;
final double bottom;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.fromLTRB(18, top, 18, bottom),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: child,
),
),
);
}
}
class PrototypeCard extends StatelessWidget {
const PrototypeCard({
super.key,
required this.child,
this.padding = const EdgeInsets.all(16),
this.margin,
this.radius = 14,
this.decoration,
});
final Widget child;
final EdgeInsetsGeometry padding;
final EdgeInsetsGeometry? margin;
final double radius;
final BoxDecoration? decoration;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Container(
margin: margin,
decoration: decoration ??
BoxDecoration(
color: cs.card,
border: Border.all(color: cs.border, width: 0.5),
borderRadius: BorderRadius.circular(radius),
),
child: Padding(padding: padding, child: child),
);
}
}
class PrototypePill extends StatelessWidget {
const PrototypePill({
super.key,
required this.label,
this.selected = false,
this.leading,
this.onTap,
});
final String label;
final bool selected;
final Widget? leading;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
final bg = selected ? cs.secondary : cs.background;
final fg = selected ? cs.secondaryForeground : cs.mutedForeground;
final borderColor = selected ? const Color(0xFFE3D6B4) : cs.border;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(999),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: borderColor, width: 0.5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (leading != null) ...[leading!, const SizedBox(width: 4)],
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: fg,
),
),
],
),
),
);
}
}
class PrototypeMetricChip extends StatelessWidget {
const PrototypeMetricChip({
super.key,
required this.label,
required this.value,
this.valueColor,
this.background,
});
final String label;
final String value;
final Color? valueColor;
final Color? background;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: background ?? cs.background,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: cs.border, width: 0.5),
),
child: RichText(
text: TextSpan(
style: TextStyle(fontSize: 10, color: cs.mutedForeground),
children: [
TextSpan(text: '$label '),
TextSpan(
text: value,
style: TextStyle(
fontWeight: FontWeight.w600,
color: valueColor ?? cs.foreground,
),
),
],
),
),
);
}
}
class PrototypeLineChart extends StatelessWidget {
const PrototypeLineChart({
super.key,
required this.values,
this.lineColor = const Color(0xFFB5862A),
this.fillColor = const Color(0xFFF7EFD9),
});
final List<double> values;
final Color lineColor;
final Color fillColor;
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: _PrototypeLineChartPainter(
values: values,
lineColor: lineColor,
fillColor: fillColor,
),
size: Size.infinite,
);
}
}
class _PrototypeLineChartPainter extends CustomPainter {
_PrototypeLineChartPainter({
required this.values,
required this.lineColor,
required this.fillColor,
});
final List<double> values;
final Color lineColor;
final Color fillColor;
@override
void paint(Canvas canvas, Size size) {
if (values.isEmpty || size.isEmpty) return;
final paintGrid = Paint()
..color = const Color(0xFFECEAE3)
..strokeWidth = 1;
final paintLine = Paint()
..color = lineColor
..style = PaintingStyle.stroke
..strokeWidth = 2.4
..strokeJoin = StrokeJoin.round
..strokeCap = StrokeCap.round;
final paintFill = Paint()
..color = fillColor
..style = PaintingStyle.fill;
const padTop = 10.0;
const padBottom = 10.0;
final plotHeight = size.height - padTop - padBottom;
final plotWidth = size.width;
final min = values.reduce((a, b) => a < b ? a : b);
final max = values.reduce((a, b) => a > b ? a : b);
final span = (max - min).abs() < 0.0001 ? 1.0 : max - min;
Offset pointFor(int index) {
final x = values.length == 1
? plotWidth / 2
: index / (values.length - 1) * plotWidth;
final y = padTop + (1 - (values[index] - min) / span) * plotHeight;
return Offset(x, y);
}
for (var i = 0; i < 4; i++) {
final dy = padTop + plotHeight * i / 3;
canvas.drawLine(Offset(0, dy), Offset(size.width, dy), paintGrid);
}
final pts = List.generate(values.length, pointFor);
final path = Path()..moveTo(pts.first.dx, pts.first.dy);
for (final p in pts.skip(1)) {
path.lineTo(p.dx, p.dy);
}
final fillPath = Path()
..addPath(path, Offset.zero)
..lineTo(size.width, size.height)
..lineTo(0, size.height)
..close();
canvas.drawPath(fillPath, paintFill);
canvas.drawPath(path, paintLine);
final last = pts.last;
canvas.drawCircle(last, 3.8, Paint()..color = lineColor);
}
@override
bool shouldRepaint(covariant _PrototypeLineChartPainter oldDelegate) {
return oldDelegate.values != values ||
oldDelegate.lineColor != lineColor ||
oldDelegate.fillColor != fillColor;
}
}
class PrototypeSectionLabel extends StatelessWidget {
const PrototypeSectionLabel(this.label, {super.key, this.trailing});
final String label;
final Widget? trailing;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
children: [
Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: cs.foreground,
),
),
const Spacer(),
if (trailing != null) trailing!,
],
),
);
}
}
class PrototypeDivider extends StatelessWidget {
const PrototypeDivider({super.key, this.margin = const EdgeInsets.symmetric(vertical: 10)});
final EdgeInsetsGeometry margin;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Container(
margin: margin,
height: 0.5,
color: cs.border,
);
}
}
-21
View File
@@ -1,21 +0,0 @@
import 'package:flutter/material.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
const ClassicFooter jinzhiClassicFooter = ClassicFooter(
height: 40,
idleIcon: const SizedBox.shrink(),
canLoadingIcon: const SizedBox.shrink(),
noMoreIcon: const SizedBox.shrink(),
failedIcon: const SizedBox.shrink(),
loadingIcon: const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
loadingText: '',
idleText: '',
canLoadingText: '',
noDataText: '',
failedText: '',
textStyle: const TextStyle(fontSize: 12, color: Colors.grey),
);
@@ -49,56 +49,6 @@ class AssetPortfolioController extends StateNotifier<PortfolioSummary> {
);
}
void updateHolding(GoldAssetHolding holding) {
final index = _holdings.indexWhere((item) => item.id == holding.id);
if (index == -1) return;
_holdings[index] = holding;
state = _buildSummary(
holdings: _holdings,
spotPriceFor: _spotPriceFor,
changeFor: _changeFor,
recycleDiscount: recycleDiscount,
);
}
void removeHolding(String id) {
_holdings.removeWhere((item) => item.id == id);
state = _buildSummary(
holdings: _holdings,
spotPriceFor: _spotPriceFor,
changeFor: _changeFor,
recycleDiscount: recycleDiscount,
);
}
void markHoldingSold(String id) {
final index = _holdings.indexWhere((item) => item.id == id);
if (index == -1) return;
final holding = _holdings[index];
_holdings[index] = GoldAssetHolding(
id: holding.id,
name: holding.name,
metal: holding.metal,
category: holding.category,
purity: holding.purity,
purityLabel: holding.purityLabel,
weightGram: holding.weightGram,
costAmount: holding.costAmount,
purchaseDate: holding.purchaseDate,
channel: holding.channel,
note: holding.note,
createdAt: holding.createdAt,
updatedAt: DateTime.now(),
status: HoldingStatus.sold,
);
state = _buildSummary(
holdings: _holdings,
spotPriceFor: _spotPriceFor,
changeFor: _changeFor,
recycleDiscount: recycleDiscount,
);
}
static PortfolioSummary _buildSummary({
required List<GoldAssetHolding> holdings,
required double Function(MetalType metal) spotPriceFor,
-163
View File
@@ -1,58 +1,5 @@
import '../../../common/domain/metal_type.dart';
class AssetImage {
const AssetImage({
required this.localId,
required this.localPath,
required this.createdAt,
this.remoteUrl,
this.thumbnailUrl,
});
final String localId;
final String localPath;
final String? remoteUrl;
final String? thumbnailUrl;
final DateTime createdAt;
AssetImage copyWith({
String? localId,
String? localPath,
String? remoteUrl,
String? thumbnailUrl,
DateTime? createdAt,
}) {
return AssetImage(
localId: localId ?? this.localId,
localPath: localPath ?? this.localPath,
remoteUrl: remoteUrl ?? this.remoteUrl,
thumbnailUrl: thumbnailUrl ?? this.thumbnailUrl,
createdAt: createdAt ?? this.createdAt,
);
}
Map<String, dynamic> toJson() {
return {
'localId': localId,
'localPath': localPath,
'remoteUrl': remoteUrl,
'thumbnailUrl': thumbnailUrl,
'createdAt': createdAt.toIso8601String(),
};
}
factory AssetImage.fromJson(Map<String, dynamic> json) {
return AssetImage(
localId: json['localId'] as String? ?? '',
localPath: json['localPath'] as String? ?? '',
remoteUrl: json['remoteUrl'] as String?,
thumbnailUrl: json['thumbnailUrl'] as String?,
createdAt: DateTime.tryParse(json['createdAt'] as String? ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0),
);
}
}
enum AssetCategory {
bar('金条'),
necklace('项链'),
@@ -81,7 +28,6 @@ class GoldAssetHolding {
required this.weightGram,
required this.createdAt,
required this.updatedAt,
this.images = const [],
this.costAmount,
this.purchaseDate,
this.channel,
@@ -96,7 +42,6 @@ class GoldAssetHolding {
final double purity;
final String purityLabel;
final double weightGram;
final List<AssetImage> images;
final double? costAmount;
final DateTime? purchaseDate;
final String? channel;
@@ -105,92 +50,7 @@ class GoldAssetHolding {
final DateTime updatedAt;
final HoldingStatus status;
GoldAssetHolding copyWith({
String? id,
String? name,
MetalType? metal,
AssetCategory? category,
double? purity,
String? purityLabel,
double? weightGram,
List<AssetImage>? images,
double? costAmount,
DateTime? purchaseDate,
String? channel,
String? note,
DateTime? createdAt,
DateTime? updatedAt,
HoldingStatus? status,
}) {
return GoldAssetHolding(
id: id ?? this.id,
name: name ?? this.name,
metal: metal ?? this.metal,
category: category ?? this.category,
purity: purity ?? this.purity,
purityLabel: purityLabel ?? this.purityLabel,
weightGram: weightGram ?? this.weightGram,
images: images ?? this.images,
costAmount: costAmount ?? this.costAmount,
purchaseDate: purchaseDate ?? this.purchaseDate,
channel: channel ?? this.channel,
note: note ?? this.note,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
status: status ?? this.status,
);
}
double materialValue(double spotPrice) => weightGram * purity * spotPrice;
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'metalId': metal.id,
'categoryId': category.name,
'purity': purity,
'purityLabel': purityLabel,
'weightGram': weightGram,
'images': images.map((item) => item.toJson()).toList(growable: false),
'costAmount': costAmount,
'purchaseDate': purchaseDate?.toIso8601String(),
'channel': channel,
'note': note,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
'status': status.name,
};
}
factory GoldAssetHolding.fromJson(Map<String, dynamic> json) {
final rawImages = json['images'];
return GoldAssetHolding(
id: json['id'] as String? ?? '',
name: json['name'] as String? ?? '',
metal: _metalFromId(json['metalId'] as String?),
category: _categoryFromId(json['categoryId'] as String?),
purity: (json['purity'] as num?)?.toDouble() ?? 1,
purityLabel: json['purityLabel'] as String? ?? '',
weightGram: (json['weightGram'] as num?)?.toDouble() ?? 0,
images: rawImages is List
? [
for (final raw in rawImages)
if (raw is Map)
AssetImage.fromJson(Map<String, dynamic>.from(raw)),
]
: const [],
costAmount: (json['costAmount'] as num?)?.toDouble(),
purchaseDate: DateTime.tryParse(json['purchaseDate'] as String? ?? ''),
channel: json['channel'] as String?,
note: json['note'] as String?,
createdAt: DateTime.tryParse(json['createdAt'] as String? ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0),
updatedAt: DateTime.tryParse(json['updatedAt'] as String? ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0),
status: _statusFromName(json['status'] as String?),
);
}
}
class HoldingValuation {
@@ -240,26 +100,3 @@ class PortfolioSummary {
final List<MetalBreakdown> breakdowns;
final List<HoldingValuation> holdings;
}
MetalType _metalFromId(String? id) {
return switch (id) {
'gold' => MetalType.gold,
'platinum' => MetalType.platinum,
'silver' => MetalType.silver,
_ => MetalType.gold,
};
}
AssetCategory _categoryFromId(String? id) {
return AssetCategory.values.firstWhere(
(item) => item.name == id,
orElse: () => AssetCategory.bar,
);
}
HoldingStatus _statusFromName(String? value) {
return HoldingStatus.values.firstWhere(
(item) => item.name == value,
orElse: () => HoldingStatus.active,
);
}
@@ -1,12 +0,0 @@
import 'package:flutter/material.dart';
import 'asset_form_page.dart';
class AssetAddPage extends StatelessWidget {
const AssetAddPage({super.key});
@override
Widget build(BuildContext context) {
return const AssetFormPage.create();
}
}
@@ -1,21 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../application/asset_portfolio_controller.dart';
import 'asset_form_page.dart';
class AssetEditPage extends ConsumerWidget {
const AssetEditPage({super.key, required this.id});
final String id;
@override
Widget build(BuildContext context, WidgetRef ref) {
final holding = ref
.watch(assetPortfolioControllerProvider)
.holdings
.map((item) => item.holding)
.firstWhere((item) => item.id == id);
return AssetFormPage.edit(holding: holding);
}
}
File diff suppressed because it is too large. Load diff
@@ -1,95 +0,0 @@
import 'dart:io';
import 'package:flutter/material.dart' hide AssetImage;
import 'package:photo_view/photo_view.dart';
import 'package:photo_view/photo_view_gallery.dart';
import '../data/asset_models.dart';
class AssetImagePreviewPage extends StatefulWidget {
const AssetImagePreviewPage({
super.key,
required this.images,
required this.initialIndex,
});
final List<AssetImage> images;
final int initialIndex;
@override
State<AssetImagePreviewPage> createState() => _AssetImagePreviewPageState();
}
class _AssetImagePreviewPageState extends State<AssetImagePreviewPage> {
late final PageController _pageController;
late int _currentIndex;
@override
void initState() {
super.initState();
_currentIndex = widget.initialIndex.clamp(0, widget.images.length - 1);
_pageController = PageController(initialPage: _currentIndex);
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Stack(
children: [
PhotoViewGallery.builder(
pageController: _pageController,
itemCount: widget.images.length,
builder: (context, index) {
final image = widget.images[index];
return PhotoViewGalleryPageOptions(
imageProvider: FileImage(File(image.localPath)),
minScale: PhotoViewComputedScale.contained,
maxScale: PhotoViewComputedScale.covered * 2.2,
);
},
onPageChanged: (index) => setState(() {
_currentIndex = index;
}),
backgroundDecoration: const BoxDecoration(color: Colors.black),
),
Positioned(
top: 16,
left: 16,
child: IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close, color: Colors.white),
),
),
Positioned(
bottom: 24,
left: 0,
right: 0,
child: Center(
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(999),
),
child: Text(
'${_currentIndex + 1}/${widget.images.length}',
style: const TextStyle(color: Colors.white, fontSize: 14),
),
),
),
),
],
),
),
);
}
}
File diff suppressed because it is too large. Load diff
@@ -1,603 +0,0 @@
import 'dart:io';
import 'package:flutter/material.dart' hide AssetImage;
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../common/domain/money.dart';
import '../../../common/router/app_router.dart';
import '../../../common/widgets/prototype_ui.dart';
import '../../profile/application/price_color_theme.dart';
import '../../profile/application/profile_settings_controller.dart';
import '../application/asset_portfolio_controller.dart';
import '../data/asset_models.dart';
import 'asset_image_preview_page.dart';
class HoldingDetailPage extends ConsumerWidget {
const HoldingDetailPage({super.key, this.id});
final String? id;
@override
Widget build(BuildContext context, WidgetRef ref) {
final summary = ref.watch(assetPortfolioControllerProvider);
final holding = summary.holdings
.map((item) => item.holding)
.firstWhere(
(item) => id == null || item.id == id,
orElse: () => summary.holdings.first.holding,
);
final valuation = summary.holdings.firstWhere(
(item) => item.holding.id == holding.id,
);
final cs = ShadTheme.of(context).colorScheme;
final palette = priceColorPalette(
ref.watch(profileSettingsControllerProvider).priceColorMode,
);
final changeUp = valuation.todayChange >= 0;
return Scaffold(
backgroundColor: const Color(0xFFFBFAF7),
appBar: AppBar(
backgroundColor: const Color(0xFFFBFAF7),
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: true,
leading: IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.chevron_left, size: 20),
),
title: Text(
holding.name,
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
actions: [
IconButton(
onPressed: () => _showActionsSheet(context, ref, holding),
icon: const Icon(Icons.more_horiz, size: 22),
color: const Color(0xFF8A8780),
),
],
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.fromLTRB(0, 8, 0, 28),
children: [
PrototypeFrame(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 4),
PrototypeCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: holding.metal.color.withValues(
alpha: 0.15,
),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
Icons.circle_outlined,
color: holding.metal.color,
size: 18,
),
),
const SizedBox(width: 10),
Text(
'${holding.purityLabel} · ${holding.weightGram.toStringAsFixed(1)}g',
style: TextStyle(
fontSize: 12,
color: cs.mutedForeground,
),
),
],
),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'¥',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 2),
Text(
formatCny(
valuation.materialValue,
).replaceFirst('¥', ''),
style: const TextStyle(
fontSize: 42,
fontWeight: FontWeight.w600,
letterSpacing: -1,
height: 1,
),
),
],
),
const SizedBox(height: 10),
Row(
children: [
_ChangeChip(
amount: valuation.todayChange,
positive: changeUp,
upColor: Color(palette.up),
downColor: Color(palette.down),
upBackground: Color(palette.upBackground),
downBackground: Color(palette.downBackground),
),
const SizedBox(width: 8),
Text(
'今日',
style: TextStyle(
fontSize: 12,
color: cs.mutedForeground,
),
),
],
),
],
),
),
const SizedBox(height: 12),
GestureDetector(
onTap: () => context.push(
'${AppRoutes.marketKline}?metal=${holding.metal.id}&period=24h',
),
child: PrototypeCard(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
child: SizedBox(
height: 92,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: PrototypeLineChart(
values: const [
18,
20,
21,
23,
22,
25,
29,
31,
30,
33,
35,
37,
],
),
),
),
),
),
const SizedBox(height: 12),
PrototypeCard(
padding: EdgeInsets.zero,
child: Column(
children: [
_InfoRow(
label: '材料价值(现货)',
value: formatCny(valuation.materialValue),
),
const PrototypeDivider(margin: EdgeInsets.zero),
_InfoRow(
label: '回收参考价',
value: formatCny(valuation.materialValue * 0.97),
),
if (holding.costAmount != null) ...[
const PrototypeDivider(margin: EdgeInsets.zero),
_InfoRow(
label: '买入成本',
value: formatCny(holding.costAmount!),
),
const PrototypeDivider(margin: EdgeInsets.zero),
_InfoRow(
label: '盈亏',
value: _signedCny(
valuation.materialValue - holding.costAmount!,
),
valueColor:
valuation.materialValue - holding.costAmount! >=
0
? Color(palette.up)
: Color(palette.down),
),
],
],
),
),
const SizedBox(height: 12),
PrototypeCard(
padding: EdgeInsets.zero,
child: Column(
children: [
_InfoRow(
label: '金属 / 纯度',
value:
'${holding.metal.label} · ${holding.purityLabel}',
),
const PrototypeDivider(margin: EdgeInsets.zero),
_InfoRow(
label: '克重',
value: '${holding.weightGram.toStringAsFixed(1)} g',
),
const PrototypeDivider(margin: EdgeInsets.zero),
_InfoRow(
label: '购买日期',
value: _dateLabel(holding.purchaseDate),
),
const PrototypeDivider(margin: EdgeInsets.zero),
_InfoRow(label: '购买渠道', value: holding.channel ?? '未填'),
const PrototypeDivider(margin: EdgeInsets.zero),
_InfoRow(
label: '备注',
value: holding.note == null ||
holding.note!.trim().isEmpty
? '未填'
: holding.note!.trim(),
),
],
),
),
const SizedBox(height: 12),
_buildImageSection(context, holding),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => context.push(
'${AppRoutes.assetEdit}?id=${holding.id}',
),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Color(0xFFE3D6B4)),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
foregroundColor: const Color(0xFF9A7A2A),
),
child: const Text('编辑'),
),
),
const SizedBox(width: 10),
Expanded(
child: OutlinedButton(
onPressed: () => _markSold(context, ref, holding.id),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Color(0xFFE6E1D8)),
foregroundColor: const Color(0xFF8A8780),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
child: const Text('标记卖出 / 转赠'),
),
),
],
),
],
),
),
],
),
),
);
}
void _showActionsSheet(
BuildContext context,
WidgetRef ref,
GoldAssetHolding holding,
) {
showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(22)),
),
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Center(
child: Container(
width: 38,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: const Color(0xFFE3DFD4),
borderRadius: BorderRadius.circular(999),
),
),
),
_ActionMenuTile(
icon: Icons.edit_outlined,
label: '编辑持仓',
onTap: () {
Navigator.of(ctx).pop();
context.push('${AppRoutes.assetEdit}?id=${holding.id}');
},
),
const PrototypeDivider(margin: EdgeInsets.zero),
_ActionMenuTile(
icon: Icons.share_outlined,
label: '分享这张的估值卡',
onTap: () {
Navigator.of(ctx).pop();
context.push(AppRoutes.profileShare);
},
),
const PrototypeDivider(margin: EdgeInsets.zero),
_ActionMenuTile(
icon: Icons.sell_outlined,
label: '标记卖出 / 转赠',
labelColor: const Color(0xFFC0392B),
iconColor: const Color(0xFFC0392B),
onTap: () {
Navigator.of(ctx).pop();
_markSold(context, ref, holding.id);
},
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消'),
),
),
],
),
),
),
);
}
void _markSold(BuildContext context, WidgetRef ref, String id) {
ref.read(assetPortfolioControllerProvider.notifier).markHoldingSold(id);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('已标记卖出 / 转赠')));
Navigator.of(context).pop();
}
Widget _buildImageSection(
BuildContext context,
GoldAssetHolding holding,
) {
final images = holding.images;
return PrototypeCard(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'凭证照片',
style: TextStyle(fontSize: 13, color: Color(0xFF8A8780)),
),
const SizedBox(height: 12),
if (images.isEmpty)
const Text(
'暂无凭证照片,可在添加资产时上传发票、证书或实物照片。',
style: TextStyle(fontSize: 12, color: Color(0xFFB3AFA5)),
)
else
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (var index = 0; index < images.length; index++)
_buildDetailImageTile(
context,
holding,
images[index],
index,
),
],
),
],
),
);
}
Widget _buildDetailImageTile(
BuildContext context,
GoldAssetHolding holding,
AssetImage image,
int index,
) {
return GestureDetector(
onTap: () => _openImagePreview(context, holding.images, index),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(
File(image.localPath),
width: 84,
height: 84,
fit: BoxFit.cover,
),
),
);
}
void _openImagePreview(
BuildContext context,
List<AssetImage> images,
int initialIndex,
) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => AssetImagePreviewPage(
images: images,
initialIndex: initialIndex,
),
),
);
}
}
class _ActionMenuTile extends StatelessWidget {
const _ActionMenuTile({
required this.icon,
required this.label,
required this.onTap,
this.iconColor,
this.labelColor,
});
final IconData icon;
final String label;
final VoidCallback onTap;
final Color? iconColor;
final Color? labelColor;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 16),
child: Row(
children: [
Icon(icon, size: 20, color: iconColor ?? const Color(0xFF8A8780)),
const SizedBox(width: 12),
Text(
label,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: labelColor ?? cs.foreground,
),
),
],
),
),
);
}
}
class _InfoRow extends StatelessWidget {
const _InfoRow({required this.label, required this.value, this.valueColor});
final String label;
final String value;
final Color? valueColor;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 11),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: TextStyle(fontSize: 13, color: cs.mutedForeground),
),
Text(
value,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: valueColor ?? cs.foreground,
),
),
],
),
);
}
}
class _ChangeChip extends StatelessWidget {
const _ChangeChip({
required this.amount,
required this.positive,
required this.upColor,
required this.downColor,
required this.upBackground,
required this.downBackground,
});
final double amount;
final bool positive;
final Color upColor;
final Color downColor;
final Color upBackground;
final Color downBackground;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: positive ? upBackground : downBackground,
borderRadius: BorderRadius.circular(999),
),
child: Text(
'${positive ? '↑' : '↓'}${formatCny(amount.abs())} · ${positive ? '+' : '-'}${amount.abs().toStringAsFixed(2)}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: positive ? upColor : downColor,
),
),
);
}
}
class _FieldRow extends StatelessWidget {
const _FieldRow({required this.label, required this.child});
final String label;
final Widget child;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFECEAE3), width: 0.5),
),
child: Row(
children: [
Text(
label,
style: const TextStyle(fontSize: 13, color: Color(0xFF8A8780)),
),
const SizedBox(width: 10),
Expanded(child: child),
],
),
);
}
}
String _dateLabel(DateTime? value) {
if (value == null) return '未填';
return '${value.year}-${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')}';
}
String _signedCny(double value) {
final sign = value >= 0 ? '+' : '-';
return '$sign${formatCny(value.abs())}';
}
@@ -669,9 +669,7 @@ class _VerifySheetState extends ConsumerState<_VerifySheet> {
final cs = ShadTheme.of(context).colorScheme;
return Padding(
// 键盘弹出时 sheet 跟随上移
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
@@ -717,7 +715,11 @@ class _VerifySheetState extends ConsumerState<_VerifySheet> {
return [
Text(
'正在清除账户数据,请稍候',
style: TextStyle(fontSize: 13, height: 1.55, color: cs.mutedForeground),
style: TextStyle(
fontSize: 13,
height: 1.55,
color: cs.mutedForeground,
),
),
const SizedBox(height: 28),
Center(
@@ -735,7 +737,11 @@ class _VerifySheetState extends ConsumerState<_VerifySheet> {
return [
Text(
'为保障账户安全,注销前需验证您的手机号。验证码将发送至 ${_maskPhone(widget.phone)}。',
style: TextStyle(fontSize: 13, height: 1.55, color: cs.mutedForeground),
style: TextStyle(
fontSize: 13,
height: 1.55,
color: cs.mutedForeground,
),
),
if (_error != null) ...[
const SizedBox(height: 10),
@@ -762,7 +768,11 @@ class _VerifySheetState extends ConsumerState<_VerifySheet> {
return [
Text(
'验证码已发送至 ${_maskPhone(widget.phone)}',
style: TextStyle(fontSize: 13, height: 1.55, color: cs.mutedForeground),
style: TextStyle(
fontSize: 13,
height: 1.55,
color: cs.mutedForeground,
),
),
const SizedBox(height: 20),
_PinInput(
@@ -854,7 +864,7 @@ class DeleteAccountSuccessPage extends ConsumerWidget {
),
const SizedBox(height: 10),
Text(
'您的账号及相关数据已清除完毕\n感谢您曾使用金值',
'您的账号及相关数据已清除完毕\n感谢您曾使用研听',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
@@ -259,7 +259,7 @@ class _PhoneStep extends StatelessWidget {
children: [
_SheetTitle('手机号登录', cs),
const SizedBox(height: 8),
_SheetSub('未注册的手机号验证后将自动创建金值账号。', cs),
_SheetSub('未注册的手机号验证后将自动创建研听账号。', cs),
const SizedBox(height: 18),
Row(
children: [
@@ -1,227 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../common/domain/metal_type.dart';
import '../../../common/widgets/prototype_ui.dart';
import '../../profile/application/price_color_theme.dart';
import '../../profile/application/profile_settings_controller.dart';
import '../data/mock_market_repository.dart';
import '../data/market_models.dart';
class MarketKlinePage extends ConsumerWidget {
const MarketKlinePage({super.key, this.metalId, this.periodId});
final String? metalId;
final String? periodId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final repo = ref.watch(mockMarketRepositoryProvider);
final metal = _metalFromId(metalId) ?? MetalType.gold;
final period = _periodFromId(periodId) ?? MarketPeriod.h24;
final quote = repo.quoteFor(metal);
final points = repo.chartFor(metal, period);
final cs = ShadTheme.of(context).colorScheme;
final palette = priceColorPalette(
ref.watch(profileSettingsControllerProvider).priceColorMode,
);
return Scaffold(
backgroundColor: const Color(0xFFFBFAF7),
appBar: AppBar(
backgroundColor: const Color(0xFFFBFAF7),
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: true,
leading: IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.chevron_left, size: 20),
),
title: Text(
'${metal.label} K线',
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.fromLTRB(0, 8, 0, 24),
children: [
PrototypeFrame(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 4),
PrototypeCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'¥',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: cs.foreground,
),
),
const SizedBox(width: 2),
Text(
quote.spotPrice.toStringAsFixed(2),
style: const TextStyle(
fontSize: 29,
fontWeight: FontWeight.w600,
letterSpacing: -1,
),
),
const SizedBox(width: 4),
const Padding(
padding: EdgeInsets.only(top: 8),
child: Text(
'元/克',
style: TextStyle(
fontSize: 12,
color: Color(0xFF8A8780),
),
),
),
],
),
const SizedBox(height: 6),
Text(
'${quote.changeAmount >= 0 ? '+' : ''}${quote.changeAmount.toStringAsFixed(2)} · ${quote.changePercent.toStringAsFixed(2)}%',
style: TextStyle(
color: quote.changeAmount >= 0
? Color(palette.up)
: Color(palette.down),
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final p in MarketPeriod.values)
PrototypePill(
label: p.label,
selected: p == period,
onTap: () =>
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => MarketKlinePage(
metalId: metal.id,
periodId: p.id,
),
),
),
),
],
),
const SizedBox(height: 10),
Row(
children: [
_Ohlc('开', points.first.open),
const SizedBox(width: 18),
_Ohlc(
'高',
points
.map((e) => e.high)
.reduce((a, b) => a > b ? a : b),
),
const SizedBox(width: 18),
_Ohlc(
'低',
points
.map((e) => e.low)
.reduce((a, b) => a < b ? a : b),
),
const SizedBox(width: 18),
_Ohlc('收', points.last.close),
],
),
const SizedBox(height: 12),
SizedBox(
height: 300,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: PrototypeLineChart(
values: points
.map((p) => p.close)
.toList(growable: false),
),
),
),
const SizedBox(height: 10),
Text(
'${quote.basisLabel} · ${_timeLabel(quote.updatedAt)}',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
color: cs.mutedForeground,
),
),
],
),
),
],
),
),
],
),
),
);
}
}
class _Ohlc extends StatelessWidget {
const _Ohlc(this.label, this.value);
final String label;
final double value;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(fontSize: 12, color: Color(0xFF8A8780)),
),
const SizedBox(height: 2),
Text(
value.toStringAsFixed(2),
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
],
);
}
}
MetalType? _metalFromId(String? id) {
return switch (id) {
'gold' => MetalType.gold,
'platinum' => MetalType.platinum,
'silver' => MetalType.silver,
_ => null,
};
}
MarketPeriod? _periodFromId(String? id) {
return switch (id) {
'24h' => MarketPeriod.h24,
'5d' => MarketPeriod.d5,
'1m' => MarketPeriod.m1,
'3m' => MarketPeriod.m3,
'1y' => MarketPeriod.y1,
_ => null,
};
}
String _timeLabel(DateTime value) {
return '${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}';
}
+91 -693
View File
@@ -1,413 +1,143 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../common/domain/metal_type.dart';
import '../../../common/domain/money.dart';
import '../../../common/router/app_router.dart';
import '../../../common/widgets/prototype_ui.dart';
import '../../profile/application/price_color_theme.dart';
import '../../profile/application/profile_settings_controller.dart';
import '../../../common/widgets/adaptive.dart';
import '../application/exchange_calculator_controller.dart';
import '../application/market_controller.dart';
import '../data/market_models.dart';
class MarketPage extends HookConsumerWidget {
class MarketPage extends ConsumerWidget {
const MarketPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final market = ref.watch(marketControllerProvider);
final exchange = ref.watch(exchangeCalculatorControllerProvider);
final palette = priceColorPalette(
ref.watch(profileSettingsControllerProvider).priceColorMode,
);
final gramCtrl = useTextEditingController(
text: exchange.gram.toStringAsFixed(2),
);
final amountCtrl = useTextEditingController(
text: exchange.amount.toStringAsFixed(2),
);
final gramFocus = useFocusNode();
final amountFocus = useFocusNode();
final cs = ShadTheme.of(context).colorScheme;
useEffect(
() {
if (!gramFocus.hasFocus) {
gramCtrl.text = exchange.gram.toStringAsFixed(
exchange.gram % 1 == 0 ? 0 : 2,
);
}
if (!amountFocus.hasFocus) {
amountCtrl.text = exchange.amount.toStringAsFixed(2);
}
return null;
},
[
exchange.gram,
exchange.amount,
exchange.selectedChannel.id,
exchange.metal.id,
],
);
return SafeArea(
top: false,
child: ListView(
padding: const EdgeInsets.fromLTRB(0, 8, 0, 20),
padding: const EdgeInsets.fromLTRB(18, 18, 18, 24),
children: [
PrototypeFrame(
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: Adaptive.contentMaxWidth,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
SegmentedButton<MetalType>(
segments: [
for (final metal in MetalType.values)
ButtonSegment(value: metal, label: Text(metal.label)),
],
selected: {market.selectedMetal},
onSelectionChanged: (next) {
final metal = next.first;
ref
.read(marketControllerProvider.notifier)
.selectMetal(metal);
ref
.read(exchangeCalculatorControllerProvider.notifier)
.selectMetal(metal);
},
),
const SizedBox(height: 12),
_Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'行情',
Text(market.quote.basisLabel, style: _mutedStyle(cs)),
const SizedBox(height: 8),
Text(
'${formatCny(market.quote.spotPrice)} / 克',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
fontSize: 30,
fontWeight: FontWeight.w800,
color: cs.foreground,
),
),
const Spacer(),
GestureDetector(
onTap: () =>
_showPriceAlertSheet(context, market.selectedMetal),
child: Container(
width: 30,
height: 30,
decoration: BoxDecoration(
color: const Color(0xFFF7F4EE),
borderRadius: BorderRadius.circular(999),
),
child: const Icon(
Icons.notifications_none_outlined,
size: 18,
color: Color(0xFF9A968D),
const SizedBox(height: 6),
Text(
'${market.quote.changeAmount >= 0 ? '+' : ''}${market.quote.changeAmount.toStringAsFixed(2)} · ${market.quote.changePercent.toStringAsFixed(2)}%',
style: TextStyle(
color: market.quote.changeAmount >= 0
? const Color(0xFFC0392B)
: const Color(0xFF2E8B6F),
),
),
const SizedBox(height: 14),
Wrap(
spacing: 8,
children: [
for (final period in MarketPeriod.values)
ChoiceChip(
label: Text(period.label),
selected: period == market.period,
onSelected: (_) => ref
.read(marketControllerProvider.notifier)
.selectPeriod(period),
),
],
),
const SizedBox(height: 14),
Row(
children: [
for (var i = 0; i < MetalType.values.length; i++) ...[
Expanded(
child: _MetalTab(
label: MetalType.values[i].label,
selected: MetalType.values[i] == market.selectedMetal,
onTap: () {
final metal = MetalType.values[i];
ref
.read(marketControllerProvider.notifier)
.selectMetal(metal);
ref
.read(
exchangeCalculatorControllerProvider.notifier,
)
.selectMetal(metal);
},
Text(
'走势图 mock 点位:${market.points.length} 个',
style: _mutedStyle(cs),
),
),
if (i != MetalType.values.length - 1)
const SizedBox(width: 8),
],
],
),
const SizedBox(height: 10),
PrototypeCard(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 13),
),
const SizedBox(height: 12),
_Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'¥',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: cs.foreground,
),
),
const SizedBox(width: 2),
Text(
formatCny(
market.quote.spotPrice,
).replaceFirst('¥', ''),
style: TextStyle(
fontSize: 29,
fontWeight: FontWeight.w600,
color: cs.foreground,
letterSpacing: -1,
height: 1,
),
),
const Padding(
padding: EdgeInsets.only(left: 4, bottom: 2),
child: Text(
'元/克',
style: TextStyle(
fontSize: 12,
color: Color(0xFF8A8780),
),
),
),
],
),
const SizedBox(height: 6),
Row(
children: [
_ChangeChip(
amount: market.quote.changeAmount,
percent: market.quote.changePercent,
upColor: Color(palette.up),
downColor: Color(palette.down),
upBackground: Color(palette.upBackground),
downBackground: Color(palette.downBackground),
),
const SizedBox(width: 8),
Text(
'报价 ${_timeLabel(market.quote.updatedAt)}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF8A8780),
),
),
const Spacer(),
GestureDetector(
onTap: () {},
child: const Icon(
Icons.info_outline,
size: 16,
color: Color(0xFFB3AFA5),
),
),
],
),
const SizedBox(height: 10),
Row(
children: [
for (final period in MarketPeriod.values) ...[
_PeriodPill(
label: period.label,
selected: period == market.period,
onTap: () => ref
.read(marketControllerProvider.notifier)
.selectPeriod(period),
),
if (period != MarketPeriod.values.last)
const SizedBox(width: 6),
],
],
),
const SizedBox(height: 8),
GestureDetector(
onTap: () => context.push(
'${AppRoutes.marketKline}?metal=${market.selectedMetal.id}&period=${market.period.id}',
),
child: SizedBox(
height: 88,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: PrototypeLineChart(
values: market.points
.map((p) => p.close)
.toList(growable: false),
),
),
),
),
const SizedBox(height: 10),
Container(height: 0.5, color: cs.border),
const SizedBox(height: 10),
Row(
children: [
const Text(
'兑换试算',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
fontSize: 16,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
),
],
const SizedBox(height: 10),
Text(
'${formatGram(exchange.gram)} ${exchange.metal.label} = ${formatCny(exchange.amount)}',
style: TextStyle(fontSize: 20, color: cs.foreground),
),
const SizedBox(height: 8),
SizedBox(
height: 32,
child: ListView.separated(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
itemCount: exchange.channels.length,
separatorBuilder: (context, index) =>
const SizedBox(width: 7),
itemBuilder: (context, index) {
final channel = exchange.channels[index];
return PrototypePill(
label: channel.name,
Text(
'1 克 ${exchange.metal.label} = ${formatCny(exchange.selectedChannel.pricePerGram)} · ${exchange.selectedChannel.source}',
style: _mutedStyle(cs),
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final channel in exchange.channels)
ChoiceChip(
label: Text(channel.name),
selected:
channel.id == exchange.selectedChannel.id,
onTap: () => ref
onSelected: (_) => ref
.read(
exchangeCalculatorControllerProvider
.notifier,
)
.selectChannel(channel.id),
);
},
),
),
const SizedBox(height: 10),
if (!exchange.swapped) ...[
_ExchangeField(
controller: gramCtrl,
focusNode: gramFocus,
unit: exchange.metal.label,
leading: '克',
onChanged: (value) {
final gram = double.tryParse(value) ?? 0;
ref
.read(
exchangeCalculatorControllerProvider.notifier,
)
.setGram(gram);
},
),
const SizedBox(height: 8),
Center(
child: GestureDetector(
onTap: () => ref
.read(
exchangeCalculatorControllerProvider.notifier,
)
.toggleSwapped(),
child: Container(
width: 30,
height: 30,
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(
color: cs.border,
width: 0.5,
),
shape: BoxShape.circle,
boxShadow: const [
BoxShadow(
color: Color(0x1F281F0C),
blurRadius: 7,
offset: Offset(0, 2),
),
],
),
child: const Icon(
Icons.swap_vert,
size: 16,
color: Color(0xFFB5862A),
),
),
),
),
const SizedBox(height: 8),
_ExchangeField(
controller: amountCtrl,
focusNode: amountFocus,
unit: 'CNY',
leading: '¥',
onChanged: (value) {
final amount = double.tryParse(value) ?? 0;
ref
.read(
exchangeCalculatorControllerProvider.notifier,
)
.setAmount(amount);
},
),
] else ...[
_ExchangeField(
controller: amountCtrl,
focusNode: amountFocus,
unit: 'CNY',
leading: '¥',
onChanged: (value) {
final amount = double.tryParse(value) ?? 0;
ref
.read(
exchangeCalculatorControllerProvider.notifier,
)
.setAmount(amount);
},
),
const SizedBox(height: 8),
Center(
child: GestureDetector(
onTap: () => ref
.read(
exchangeCalculatorControllerProvider.notifier,
)
.toggleSwapped(),
child: Container(
width: 30,
height: 30,
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(
color: cs.border,
width: 0.5,
),
shape: BoxShape.circle,
boxShadow: const [
BoxShadow(
color: Color(0x1F281F0C),
blurRadius: 7,
offset: Offset(0, 2),
),
],
),
child: const Icon(
Icons.swap_vert,
size: 16,
color: Color(0xFFB5862A),
),
),
),
),
const SizedBox(height: 8),
_ExchangeField(
controller: gramCtrl,
focusNode: gramFocus,
unit: exchange.metal.label,
leading: '克',
onChanged: (value) {
final gram = double.tryParse(value) ?? 0;
ref
.read(
exchangeCalculatorControllerProvider.notifier,
)
.setGram(gram);
},
),
],
const SizedBox(height: 10),
Text(
'1 克 ${exchange.metal.label} = ${formatCny(exchange.selectedChannel.pricePerGram)} · ${exchange.selectedChannel.source} · ${_timeLabel(exchange.selectedChannel.updatedAt)} · 参考',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF6E6A60),
),
),
const SizedBox(height: 5),
Text(
exchange.selectedChannel.hint,
style: const TextStyle(
fontSize: 11,
color: Color(0xFFB3AFA5),
height: 1.5,
),
style: _mutedStyle(cs),
),
],
),
@@ -415,364 +145,32 @@ class MarketPage extends HookConsumerWidget {
],
),
),
),
],
),
);
}
}
class _ExchangeField extends StatelessWidget {
const _ExchangeField({
required this.controller,
required this.focusNode,
required this.unit,
required this.leading,
required this.onChanged,
});
class _Card extends StatelessWidget {
const _Card({required this.child});
final TextEditingController controller;
final FocusNode focusNode;
final String unit;
final String leading;
final ValueChanged<String> onChanged;
final Widget child;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 9),
final cs = ShadTheme.of(context).colorScheme;
return DecoratedBox(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(13),
border: Border.all(color: const Color(0xFFE3D6B4), width: 0.5),
),
child: Row(
children: [
Text(
leading,
style: const TextStyle(fontSize: 14, color: Color(0xFF8A8780)),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: controller,
focusNode: focusNode,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
textAlign: TextAlign.right,
decoration: const InputDecoration(
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.zero,
),
style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w600),
onChanged: onChanged,
),
),
const SizedBox(width: 8),
Text(
unit,
style: const TextStyle(fontSize: 14, color: Color(0xFF8A8780)),
),
],
),
);
}
}
class _PeriodPill extends StatelessWidget {
const _PeriodPill({
required this.label,
required this.selected,
required this.onTap,
});
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return PrototypePill(label: label, selected: selected, onTap: onTap);
}
}
class _MetalTab extends StatelessWidget {
const _MetalTab({
required this.label,
required this.selected,
required this.onTap,
});
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
color: cs.card,
border: Border.all(color: cs.border),
borderRadius: BorderRadius.circular(12),
child: Container(
height: 32,
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected ? const Color(0xFFF6ECD6) : Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: selected ? const Color(0xFFE3D6B4) : const Color(0xFFE6E1D8),
width: 0.5,
),
),
child: Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: selected ? const Color(0xFF7A5B12) : const Color(0xFF6E6A60),
),
),
),
child: Padding(padding: const EdgeInsets.all(16), child: child),
);
}
}
class _ChangeChip extends StatelessWidget {
const _ChangeChip({
required this.amount,
required this.percent,
required this.upColor,
required this.downColor,
required this.upBackground,
required this.downBackground,
});
final double amount;
final double percent;
final Color upColor;
final Color downColor;
final Color upBackground;
final Color downBackground;
@override
Widget build(BuildContext context) {
final positive = amount >= 0;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: positive ? upBackground : downBackground,
borderRadius: BorderRadius.circular(999),
),
child: Text(
'${positive ? '↑' : '↓'}¥${amount.abs().toStringAsFixed(2)} · ${positive ? '+' : '-'}${percent.abs().toStringAsFixed(2)}%',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: positive ? upColor : downColor,
),
),
);
}
}
Future<void> _showPriceAlertSheet(BuildContext context, MetalType metal) async {
var dir = 'up';
final controller = TextEditingController(text: '960.00');
try {
await showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.white,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(22)),
),
builder: (ctx) {
return StatefulBuilder(
builder: (ctx, setState) {
return SafeArea(
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(ctx).bottom,
),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 38,
height: 4,
margin: const EdgeInsets.only(bottom: 14),
decoration: BoxDecoration(
color: const Color(0xFFE3DFD4),
borderRadius: BorderRadius.circular(999),
),
),
),
const Text(
'设置到价提醒',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
'${metal.label} 到价时提醒你',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF8A8780),
),
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => setState(() => dir = 'up'),
child: Container(
padding: const EdgeInsets.symmetric(
vertical: 10,
),
decoration: BoxDecoration(
color: dir == 'up'
? const Color(0xFFF6ECD6)
: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: dir == 'up'
? const Color(0xFFE3D6B4)
: const Color(0xFFECEAE3),
width: 0.5,
),
),
child: const Text(
'涨到',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: GestureDetector(
onTap: () => setState(() => dir = 'down'),
child: Container(
padding: const EdgeInsets.symmetric(
vertical: 10,
),
decoration: BoxDecoration(
color: dir == 'down'
? const Color(0xFFF6ECD6)
: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: dir == 'down'
? const Color(0xFFE3D6B4)
: const Color(0xFFECEAE3),
width: 0.5,
),
),
child: const Text(
'跌到',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
),
),
],
),
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 13,
vertical: 10,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(13),
border: Border.all(
color: const Color(0xFFE3D6B4),
width: 0.5,
),
),
child: Row(
children: [
const Text(
'¥',
style: TextStyle(
fontSize: 14,
color: Color(0xFF8A8780),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: controller,
keyboardType:
const TextInputType.numberWithOptions(
decimal: true,
),
textAlign: TextAlign.right,
decoration: const InputDecoration(
border: InputBorder.none,
isDense: true,
),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 8),
const Text(
'元/克',
style: TextStyle(
fontSize: 13,
color: Color(0xFF8A8780),
),
),
],
),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () {
Navigator.of(ctx).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'已设到价提醒 · $metal ${dir == 'up' ? '涨到' : '跌到'} ¥${controller.text}',
),
),
);
},
child: const Text('完成'),
),
],
),
),
),
);
},
);
},
);
} finally {
controller.dispose();
}
}
String _timeLabel(DateTime value) {
return '${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}';
TextStyle _mutedStyle(ShadColorScheme cs) {
return TextStyle(fontSize: 13, color: cs.mutedForeground, letterSpacing: 0);
}
@@ -8,17 +8,10 @@ final newsControllerProvider = StateNotifierProvider<NewsController, NewsState>(
);
class NewsState {
const NewsState({
required this.items,
required this.refreshedAt,
required this.hasMore,
required this.loadingMore,
});
const NewsState({required this.items, required this.refreshedAt});
final List<NewsItem> items;
final DateTime refreshedAt;
final bool hasMore;
final bool loadingMore;
}
class NewsController extends StateNotifier<NewsState> {
@@ -27,42 +20,15 @@ class NewsController extends StateNotifier<NewsState> {
NewsState(
items: _repository.loadFeed(),
refreshedAt: DateTime(2026, 6, 18, 16, 11),
hasMore: true,
loadingMore: false,
),
);
final MockNewsRepository _repository;
static const _pageSize = 3;
void refresh() {
final items = _repository.loadFeed(limit: _pageSize);
state = NewsState(
items: items,
items: _repository.loadFeed(),
refreshedAt: DateTime.now(),
hasMore: items.length == _pageSize,
loadingMore: false,
);
}
bool loadMore() {
if (state.loadingMore || !state.hasMore) return state.hasMore;
state = NewsState(
items: state.items,
refreshedAt: state.refreshedAt,
hasMore: state.hasMore,
loadingMore: true,
);
final next = _repository.loadFeed(
offset: state.items.length,
limit: _pageSize,
);
state = NewsState(
items: [...state.items, ...next],
refreshedAt: state.refreshedAt,
hasMore: next.length == _pageSize,
loadingMore: false,
);
return state.hasMore;
}
}
@@ -8,7 +8,7 @@ final mockNewsRepositoryProvider = Provider<MockNewsRepository>(
);
class MockNewsRepository {
List<NewsItem> loadFeed({int offset = 0, int limit = 3}) {
List<NewsItem> loadFeed() {
final base = DateTime(2026, 6, 18, 16, 11);
final items = [
NewsItem(
@@ -23,18 +23,6 @@ class MockNewsRepository {
'对持有者而言,材料价值更适合用于日常估值;对购买者而言,需要额外关注工费、品牌溢价与回收折扣。',
],
),
NewsItem(
id: 'news_gold_002',
metal: MetalType.gold,
title: '金饰零售端促销频繁,注意工费与克重拆分',
source: '门店观察',
publishedAt: base.subtract(const Duration(hours: 3, minutes: 12)),
summary: '促销券常常只影响零售支付价,材料价值并不会同步下降。',
body: const [
'金饰零售端的活动更偏向工费减免、赠品或满减,对材料价值本身没有直接影响。',
'记录资产时建议保留克重、纯度、成本和购买渠道,便于后续估值对照。',
],
),
NewsItem(
id: 'news_silver_001',
metal: MetalType.silver,
@@ -47,18 +35,6 @@ class MockNewsRepository {
'银饰通常包含较高加工与零售成本,材料价值占比可能低于用户直觉。',
],
),
NewsItem(
id: 'news_silver_002',
metal: MetalType.silver,
title: '白银工艺件价格波动更敏感,建议看实时材料价',
source: '市场简报',
publishedAt: base.subtract(const Duration(hours: 4, minutes: 8)),
summary: '白银饰品受工费和零售渠道影响更大,短线估值容易偏离材料价值。',
body: const [
'白银工艺件的零售价常常受加工复杂度影响,和材料价值的差异更明显。',
'如果只看零售价很容易高估持仓,记录时建议同时看克重和纯度。',
],
),
NewsItem(
id: 'news_platinum_001',
metal: MetalType.platinum,
@@ -71,21 +47,7 @@ class MockNewsRepository {
'记录持仓时建议保留纯度、克重、购买渠道和成本信息,便于后续估值与盈亏回看。',
],
),
NewsItem(
id: 'news_platinum_002',
metal: MetalType.platinum,
title: '铂金回收询价分层明显,渠道差异值得记录',
source: '金属观察',
publishedAt: base.subtract(const Duration(hours: 5, minutes: 16)),
summary: '不同渠道对 PT950 的检测和报价方式并不一样,保存渠道信息很重要。',
body: const [
'铂金回收通常会区分门店、品牌和回收市场,检测流程与报价节奏都有差异。',
'把渠道信息留在资产记录里,后面回看更容易对上真实回收路径。',
],
),
];
final sorted = [...items]
..sort((a, b) => b.publishedAt.compareTo(a.publishedAt));
return sorted.skip(offset).take(limit).toList(growable: false);
return [...items]..sort((a, b) => b.publishedAt.compareTo(a.publishedAt));
}
}
@@ -1,148 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../common/config/app_h5_urls.dart';
import '../../../common/util/share_util.dart';
import '../../../common/widgets/app_toast.dart';
import '../../../common/widgets/prototype_ui.dart';
import '../application/news_controller.dart';
class NewsDetailPage extends ConsumerWidget {
const NewsDetailPage({super.key, this.id});
final String? id;
@override
Widget build(BuildContext context, WidgetRef ref) {
final news = ref.watch(newsControllerProvider).items;
final item = news.firstWhere(
(item) => id == null || item.id == id,
orElse: () => news.first,
);
final cs = ShadTheme.of(context).colorScheme;
return Scaffold(
backgroundColor: const Color(0xFFFBFAF7),
appBar: AppBar(
backgroundColor: const Color(0xFFFBFAF7),
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: true,
leading: IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.chevron_left, size: 20),
),
title: Text(
item.metal.label,
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
actions: [
Builder(
builder: (buttonContext) => IconButton(
onPressed: () async {
final shareText = [
item.title,
'${item.source} · ${_timeLabel(item.publishedAt)} · ${item.metal.label}',
item.summary,
'打开金值查看:${AppH5Urls.baseUrl}/news/detail?id=${item.id}',
].join('\n');
try {
await sharePlainText(
text: shareText,
subject: item.title,
sharePositionOrigin: shareOriginForContext(buttonContext),
);
} catch (e) {
debugPrint('share news failed: $e');
if (!context.mounted) return;
await toastInfo(msg: '分享失败');
}
},
icon: const Icon(Icons.ios_share_outlined, size: 20),
color: const Color(0xFF8A8780),
),
),
],
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.fromLTRB(0, 8, 0, 24),
children: [
PrototypeFrame(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 4),
PrototypeCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: item.metal.color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
),
child: Text(
item.metal.label,
style: TextStyle(
color: item.metal.color,
fontWeight: FontWeight.w700,
fontSize: 12,
),
),
),
const SizedBox(width: 8),
Text(
'${item.source} · ${_timeLabel(item.publishedAt)}',
style: TextStyle(
fontSize: 12,
color: cs.mutedForeground,
),
),
],
),
const SizedBox(height: 12),
Text(
item.title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
height: 1.4,
),
),
const SizedBox(height: 12),
for (final paragraph in item.body) ...[
Text(
paragraph,
style: TextStyle(
fontSize: 15,
height: 1.7,
color: cs.foreground,
),
),
const SizedBox(height: 12),
],
],
),
),
],
),
),
],
),
),
);
}
}
String _timeLabel(DateTime value) {
return '${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}';
}
+46 -93
View File
@@ -1,73 +1,60 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../common/router/app_router.dart';
import '../../../common/widgets/prototype_ui.dart';
import '../../../common/widgets/adaptive.dart';
import '../application/news_controller.dart';
class NewsPage extends HookConsumerWidget {
class NewsPage extends ConsumerWidget {
const NewsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final news = ref.watch(newsControllerProvider);
final notifier = ref.read(newsControllerProvider.notifier);
final cs = ShadTheme.of(context).colorScheme;
final controller = useMemoized(
() => RefreshController(initialRefresh: false),
);
useEffect(() {
return controller.dispose;
}, [controller]);
return SafeArea(
top: false,
child: SmartRefresher(
controller: controller,
enablePullDown: true,
enablePullUp: true,
header: const MaterialClassicHeader(),
onRefresh: () async {
notifier.refresh();
controller.refreshCompleted();
},
onLoading: () async {
if (!news.hasMore || news.loadingMore) {
controller.loadNoData();
return;
}
final hasMore = notifier.loadMore();
if (hasMore) {
controller.loadComplete();
} else {
controller.loadNoData();
}
},
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(0, 8, 0, 20),
padding: const EdgeInsets.fromLTRB(18, 18, 18, 24),
children: [
PrototypeFrame(
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: Adaptive.contentMaxWidth,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'资讯',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
Row(
children: [
Expanded(
child: Text(
'信息整理 · 非投资建议',
style: TextStyle(
color: cs.mutedForeground,
fontSize: 13,
),
const SizedBox(height: 10),
for (final item in news.items) ...[
PrototypeCard(
margin: const EdgeInsets.only(bottom: 10),
child: InkWell(
onTap: () => context.push(
'${AppRoutes.newsDetail}?id=${item.id}',
),
),
TextButton(
onPressed: () =>
ref.read(newsControllerProvider.notifier).refresh(),
child: const Text('刷新'),
),
],
),
const SizedBox(height: 8),
for (final item in news.items)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: DecoratedBox(
decoration: BoxDecoration(
color: cs.card,
border: Border.all(color: cs.border),
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -89,41 +76,39 @@ class NewsPage extends HookConsumerWidget {
style: TextStyle(
color: item.metal.color,
fontWeight: FontWeight.w700,
fontSize: 12,
),
),
),
const SizedBox(width: 8),
Text(
'${item.source} · ${_timeLabel(item.publishedAt)}',
'${item.source} · ${item.publishedAt.hour.toString().padLeft(2, '0')}:${item.publishedAt.minute.toString().padLeft(2, '0')}',
style: TextStyle(
color: cs.mutedForeground,
fontSize: 12,
color: cs.mutedForeground,
),
),
const Spacer(),
Icon(
Icons.chevron_right,
size: 16,
color: cs.mutedForeground,
),
],
),
const SizedBox(height: 10),
Text(
item.title,
style: const TextStyle(
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
),
const SizedBox(height: 8),
Text(
item.summary,
style: TextStyle(
fontSize: 14,
color: cs.mutedForeground,
height: 1.55,
height: 1.5,
),
),
],
),
),
),
),
],
@@ -131,39 +116,7 @@ class NewsPage extends HookConsumerWidget {
),
),
],
if (news.loadingMore)
const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
if (!news.hasMore)
Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Text(
'没有更多资讯了',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
color: cs.mutedForeground,
),
),
),
],
),
),
],
),
),
);
}
}
String _timeLabel(DateTime value) {
return '${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}';
}
@@ -1,36 +0,0 @@
import '../data/profile_models.dart';
class PriceColorPalette {
const PriceColorPalette({
required this.up,
required this.down,
required this.upBackground,
required this.downBackground,
});
final int up;
final int down;
final int upBackground;
final int downBackground;
}
const _redUpGreenDown = PriceColorPalette(
up: 0xFFC0392B,
down: 0xFF2E8B6F,
upBackground: 0xFFFBEDEA,
downBackground: 0xFFE7F2EE,
);
const _greenUpRedDown = PriceColorPalette(
up: 0xFF2E8B6F,
down: 0xFFC0392B,
upBackground: 0xFFE7F2EE,
downBackground: 0xFFFBEDEA,
);
PriceColorPalette priceColorPalette(PriceColorMode mode) {
return switch (mode) {
PriceColorMode.redUpGreenDown => _redUpGreenDown,
PriceColorMode.greenUpRedDown => _greenUpRedDown,
};
}
@@ -40,13 +40,4 @@ class ProfileSettingsController extends StateNotifier<ProfileSettings> {
void setPriceColorMode(PriceColorMode mode) {
state = state.copyWith(priceColorMode: mode);
}
void setRecycleDiscount(MetalType metal, double value) {
state = state.copyWith(
recycleDiscounts: {
...state.recycleDiscounts,
metal: value.clamp(0.7, 1.0),
},
);
}
}
File diff suppressed because it is too large. Load diff
@@ -1,18 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter_remix/flutter_remix.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:url_launcher/url_launcher_string.dart';
import '../../../common/config/app_h5_urls.dart';
import '../../../common/router/app_router.dart';
import '../../../common/widgets/app_toast.dart';
import '../../../common/widgets/prototype_ui.dart';
import '../../../common/widgets/adaptive.dart';
import '../../auth/application/auth_controller.dart';
import '../../auth/data/auth_models.dart';
import '../application/profile_settings_controller.dart';
import '../data/profile_models.dart';
import '../../profile/application/profile_settings_controller.dart';
class ProfilePage extends ConsumerWidget {
const ProfilePage({super.key});
@@ -30,381 +25,161 @@ class ProfilePage extends ConsumerWidget {
return SafeArea(
top: false,
child: ListView(
padding: const EdgeInsets.fromLTRB(0, 8, 0, 20),
padding: const EdgeInsets.fromLTRB(18, 18, 18, 24),
children: [
PrototypeFrame(
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: Adaptive.contentMaxWidth,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'我的',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
const SizedBox(height: 14),
PrototypeCard(
_Card(
child: Row(
children: [
Container(
width: 46,
height: 46,
decoration: BoxDecoration(
color: const Color(0xFFF1EFE9),
borderRadius: BorderRadius.circular(999),
),
child: const Icon(
Icons.person_outline,
color: Color(0xFF9A968D),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
currentUser?.nickname ?? '未登录',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
currentUser?.phone ?? '登录后可云备份、多端同步',
style: TextStyle(
fontSize: 12,
color: cs.mutedForeground,
fontSize: 18,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
),
const SizedBox(height: 6),
Text(
currentUser?.phone ?? '登录后可使用本地同步、云备份占位和账户管理。',
style: TextStyle(color: cs.mutedForeground),
),
],
),
),
if (!loggedIn)
FilledButton(
onPressed: () => context.push(AppRoutes.login),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
minimumSize: Size.zero,
),
child: Text('登录'),
onPressed: () => context.push(AppRoutes.settings),
child: const Text('设置'),
),
],
),
),
const SizedBox(height: 12),
PrototypeCard(
padding: EdgeInsets.zero,
_Card(
child: Column(
children: [
_MenuRow(
title: '邀请好友,各得奖励',
subtitle: '需登录',
icon: Icons.card_giftcard_outlined,
onTap: () => _showInviteSheet(context),
),
const PrototypeDivider(margin: EdgeInsets.zero),
_MenuRow(
title: '分享我的贵金属估值卡',
subtitle: '免登录 · 自动打码金额',
icon: Icons.share_outlined,
onTap: () =>
_showShareCardSheet(context, currentUser?.nickname),
),
],
_SettingRow(
label: '隐藏金额',
value: settings.amountHidden ? '已隐藏' : '显示中',
trailing: Switch(
value: settings.amountHidden,
onChanged: (_) => ref
.read(
profileSettingsControllerProvider.notifier,
)
.toggleAmountHidden(),
),
),
const SizedBox(height: 12),
// PrototypeCard(
// padding: EdgeInsets.zero,
// child: Column(
// children: [
// SettingRow(
// icon: Icons.download_outlined,
// title: '本地导出备份',
// trailing: '导出',
// onTap: () => _showLocalBackupSheet(context),
// ),
// const PrototypeDivider(margin: EdgeInsets.zero),
// SettingRow(
// icon: Icons.cloud_outlined,
// title: '云备份 · 多端同步',
// trailing: loggedIn ? '已登录' : '登录解锁',
// onTap: () => _showCloudBackupSheet(context, loggedIn),
// ),
// ],
// ),
// ),
// const SizedBox(height: 12),
PrototypeCard(
padding: EdgeInsets.zero,
child: Column(
children: [
SettingRow(
icon: Icons.tune_outlined,
title: '纯度系数管理',
trailing: '${settings.purityPresets.length} 项',
onTap: () => _showPuritySheet(context, settings),
),
const PrototypeDivider(margin: EdgeInsets.zero),
SettingRow(
icon: Icons.percent_outlined,
title: '回收折扣设置',
trailing:
_SettingRow(
label: '回收折扣',
value:
'金 ${(settings.recycleDiscounts.values.first * 100).toStringAsFixed(0)}%',
onTap: () => _showRecycleSheet(context, settings),
),
const PrototypeDivider(margin: EdgeInsets.zero),
SettingRow(
icon: Icons.palette_outlined,
title: '涨跌颜色',
trailing: settings.priceColorMode.label,
onTap: () {
final nextMode =
settings.priceColorMode ==
PriceColorMode.redUpGreenDown
? PriceColorMode.greenUpRedDown
: PriceColorMode.redUpGreenDown;
ref
.read(profileSettingsControllerProvider.notifier)
.setPriceColorMode(nextMode);
toastInfo(msg: '已切换为「${nextMode.label}」');
},
_SettingRow(
label: '涨跌颜色',
value: settings.priceColorMode.label,
),
const PrototypeDivider(margin: EdgeInsets.zero),
SettingRow(
icon: Icons.currency_yen_outlined,
title: '显示币种',
trailing: '¥ CNY',
onTap: () => toastInfo(msg: '当前仅支持 ¥ CNY'),
),
const PrototypeDivider(margin: EdgeInsets.zero),
SettingRow(
icon: Icons.grid_view_outlined,
title: '添加桌面小组件',
trailing: '占位',
onTap: () => _showWidgetSheet(context),
_SettingRow(
label: '纯度系数',
value: '${settings.purityPresets.length} 项',
),
],
),
),
const SizedBox(height: 12),
PrototypeCard(
padding: EdgeInsets.zero,
_Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SettingRow(
icon: Icons.settings,
title: '设置',
trailing: null,
onTap: () => context.push(AppRoutes.settings),
FilledButton.tonal(
onPressed: loggedIn
? () => context.push(AppRoutes.login)
: () => context.push(AppRoutes.login),
child: Text(loggedIn ? '切换账号' : '去登录'),
),
const PrototypeDivider(margin: EdgeInsets.zero),
SettingRow(
icon: FlutterRemix.file_list_3_line,
title: '用户协议',
onTap: () => launchUrlString(
AppH5UrlsHelper.withCacheBuster(
AppH5Urls.userProtocolUrl,
const SizedBox(height: 10),
OutlinedButton(
onPressed: () => context.push(AppRoutes.settings),
child: const Text('打开设置'),
),
mode: LaunchMode.inAppBrowserView,
),
),
const PrototypeDivider(margin: EdgeInsets.zero),
SettingRow(
icon: FlutterRemix.shield_check_line,
title: '隐私政策',
onTap: () => launchUrlString(
AppH5UrlsHelper.withCacheBuster(AppH5Urls.privacyUrl),
mode: LaunchMode.inAppBrowserView,
),
),
const PrototypeDivider(margin: EdgeInsets.zero),
SettingRow(
icon: Icons.info_outline,
title: '免责声明',
trailing: null,
onTap: () => _showDisclaimer(context),
),
// const PrototypeDivider(margin: EdgeInsets.zero),
// SettingRow(
// icon: Icons.feedback_outlined,
// title: '意见反馈 · 关于金值',
// trailing: null,
// onTap: () => _showAbout(context),
// ),
],
),
),
const SizedBox(height: 14),
PrototypeCard(
const SizedBox(height: 12),
_Card(
child: Text(
'风险提示 · 数据来源 · 免责\n金值是贵金属资产记录与估值工具,所有信息不构成投资建议。\n· 行情 / 兑换价格均为参考,标注来源与更新时间,实际以交易系统 / 门店为准。\n· 材料价值 = 克重 × 纯度系数 × 上海金现货,非金店零售价(含工费 / 品牌溢价)、非回收实收价。\n· 资讯为信息整理,非投资建议;历史走势不代表未来。',
'风险提示 · 数据来源 · 免责\n价格、资讯与估值均为本地 mock 数据,仅用于产品原型验证,不构成投资建议。',
style: TextStyle(
color: cs.mutedForeground,
height: 1.7,
fontSize: 12,
height: 1.6,
fontSize: 13,
),
),
),
],
),
),
),
],
),
);
}
void _showDisclaimer(BuildContext context) {
context.push(AppRoutes.profileDisclaimer);
}
Future<void> _showInviteSheet(BuildContext context) async {
context.push(AppRoutes.profileInvite);
}
Future<void> _showShareCardSheet(
BuildContext context,
String? nickname,
) async {
context.push(AppRoutes.profileShare);
}
Future<void> _showLocalBackupSheet(BuildContext context) async {
context.push(AppRoutes.profileLocalBackup);
}
Future<void> _showCloudBackupSheet(
BuildContext context,
bool loggedIn,
) async {
context.push(AppRoutes.profileCloudBackup);
}
Future<void> _showWidgetSheet(BuildContext context) async {
context.push(AppRoutes.profileWidget);
}
Future<void> _showPuritySheet(
BuildContext context,
ProfileSettings settings,
) async {
context.push(AppRoutes.profilePurity);
}
Future<void> _showRecycleSheet(
BuildContext context,
ProfileSettings settings,
) async {
context.push(AppRoutes.profileRecycle);
}
void _showAbout(BuildContext context) {
context.push(AppRoutes.profileAbout);
}
}
class _MenuRow extends StatelessWidget {
const _MenuRow({
required this.title,
required this.subtitle,
required this.icon,
required this.onTap,
});
class _SettingRow extends StatelessWidget {
const _SettingRow({required this.label, required this.value, this.trailing});
final String title;
final String subtitle;
final IconData icon;
final VoidCallback onTap;
final String label;
final String value;
final Widget? trailing;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
children: [
Icon(icon, size: 20, color: const Color(0xFF8A8780)),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
),
],
),
),
Icon(Icons.chevron_right, size: 16, color: cs.mutedForeground),
],
),
),
);
}
}
class SettingRow extends StatelessWidget {
const SettingRow({
required this.icon,
required this.title,
required this.onTap,
this.trailing,
this.destructive = false,
});
final IconData icon;
final String title;
final String? trailing;
final VoidCallback onTap;
final bool destructive;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
final color = destructive ? cs.destructive : cs.foreground;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Icon(icon, size: 20, color: const Color(0xFF8A8780)),
const SizedBox(width: 12),
Expanded(
child: Text(
title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: color,
label,
style: TextStyle(fontSize: 15, color: cs.foreground),
),
),
),
if (trailing != null)
Text(
trailing!,
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
),
const SizedBox(width: 6),
Icon(Icons.chevron_right, size: 16, color: cs.mutedForeground),
Text(value, style: TextStyle(color: cs.mutedForeground)),
if (trailing != null) ...[const SizedBox(width: 8), trailing!],
],
),
),
);
}
}
class _Card extends StatelessWidget {
const _Card({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return DecoratedBox(
decoration: BoxDecoration(
color: cs.card,
border: Border.all(color: cs.border),
borderRadius: BorderRadius.circular(12),
),
child: Padding(padding: const EdgeInsets.all(16), child: child),
);
}
}
@@ -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;
}
@@ -119,7 +119,7 @@ class _PrivacyConsentPageState extends State<PrivacyConsentPage> {
const _LaunchMark(size: 88),
const SizedBox(height: 26),
Text(
'金值',
'研听',
style: TextStyle(
fontSize: 42,
fontWeight: FontWeight.w800,
@@ -308,7 +308,7 @@ class _PrivacyConsentSheetState extends State<_PrivacyConsentSheet> {
children: [
_grip(cs),
Text(
'欢迎使用金值',
'欢迎使用研听',
style: TextStyle(
fontSize: 19,
fontWeight: FontWeight.w700,
File diff suppressed because it is too large. Load diff
-2
View File
@@ -1,2 +0,0 @@
# Auto detect text files and perform LF normalization
* text=auto
-37
View File
@@ -1,37 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
- package-ecosystem: "pub"
directory: "/"
schedule:
interval: "daily"
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-minor", "version-update:semver-patch"]
- package-ecosystem: "pub"
directory: "example"
schedule:
interval: "daily"
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-minor", "version-update:semver-patch"]
- package-ecosystem: "gradle"
directory: "android"
schedule:
interval: "daily"
- package-ecosystem: "gradle"
directory: "example/android"
schedule:
interval: "daily"
@@ -1,34 +0,0 @@
name: Flutter build example
on:
workflow_dispatch:
pull_request:
push:
branches:
- main
jobs:
build_android:
name: Build example for Android
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Install Flutter
uses: subosito/flutter-action@v2
- name: Disable analytics
run: flutter config --no-analytics
- name: Flutter pub get
run: flutter pub get
- name: Flutter build appbundle
run: |
cd example
flutter build appbundle --release
-101
View File
@@ -1,101 +0,0 @@
name: Flutter checks
on:
workflow_dispatch:
pull_request:
push:
branches:
- main
jobs:
check_pub_dependencies:
name: Check dependencies
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
- name: Install Flutter
uses: subosito/flutter-action@v2
- name: Disable analytics
run: flutter config --no-analytics
- name: Flutter pub get
run: flutter pub get
check_formatting:
name: Check formatting
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
- name: Install Flutter
uses: subosito/flutter-action@v2
- name: Disable analytics
run: flutter config --no-analytics
- name: Flutter pub get
run: flutter pub get
- name: Check Dart formatting
run: dart format --line-length 80 --set-exit-if-changed .
analyze:
name: Dart analyze
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
- name: Install Flutter
uses: subosito/flutter-action@v2
- name: Disable analytics
run: flutter config --no-analytics
- name: Flutter pub get
run: flutter pub get
- name: Run Dart analyze
uses: invertase/github-action-dart-analyzer@v3.0.0
test:
name: Flutter test
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
- name: Install Flutter
uses: subosito/flutter-action@v2
- name: Disable analytics
run: flutter config --no-analytics
- name: Flutter pub get
run: flutter pub get
- name: Flutter test
run: flutter test
test_example:
name: Flutter test example
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
- name: Install Flutter
uses: subosito/flutter-action@v2
- name: Disable analytics
run: flutter config --no-analytics
- name: Flutter pub get
run: |
cd example
flutter pub get
- name: Flutter test example
run: |
cd example
flutter test
-18
View File
@@ -1,18 +0,0 @@
name: Pub publish
on:
release:
types: [published]
jobs:
publish:
name: Pub publish
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
- name: Publish
uses: k-paxian/dart-package-publisher@v1.6
with:
credentialJson: ${{ secrets.CREDENTIALS }}
@@ -1,21 +0,0 @@
name: Pub publish dry run
on:
workflow_dispatch:
push:
branches:
- main
jobs:
publish-dry-run:
name: Pub publish dry run
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
- name: Publish dry run
uses: k-paxian/dart-package-publisher@v1.6
with:
credentialJson: 'MockCredentialJson'
dryRunOnly: true
-32
View File
@@ -1,32 +0,0 @@
name: Check pub score
on:
workflow_dispatch:
pull_request:
push:
branches:
- main
jobs:
check-score:
name: Check pub score
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
- uses: axel-op/dart-package-analyzer@v3
id: workflow
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
- name: Check score
env:
TOTAL: ${{ steps.workflow.outputs.total }}
TOTAL_MAX: ${{ steps.workflow.outputs.total_max }}
run: |
MISSING_POINTS=$(( $TOTAL_MAX - $TOTAL ))
if (( MISSING_POINTS > 0 ))
then
echo Score can be improved!
exit 1
fi
-26
View File
@@ -1,26 +0,0 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
.vscode/
# Flutter/Dart/Pub related
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
/pubspec.lock
**/doc/api/
.dart_tool/
.packages
build/
-30
View File
@@ -1,30 +0,0 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "a14f74ff3a1cbd521163c5f03d68113d50af93d3"
channel: "stable"
project_type: plugin
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: a14f74ff3a1cbd521163c5f03d68113d50af93d3
base_revision: a14f74ff3a1cbd521163c5f03d68113d50af93d3
- platform: android
create_revision: a14f74ff3a1cbd521163c5f03d68113d50af93d3
base_revision: a14f74ff3a1cbd521163c5f03d68113d50af93d3
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
-103
View File
@@ -1,103 +0,0 @@
## 0.4.0
* Upgrade Android dependencies (Gradle, AGP, Java, Kotlin)
* Widen `flutter_lints` constraints
## 0.3.6
* Return `null` on platforms other than Android, instead of throwing an exception
## 0.3.5
* Maintenance release
* Update `kotlin` and `gradle`
## 0.3.4
* Refactor native code
## 0.3.3
* Add topics to pubspec.yaml
## 0.3.2
* Improve `namespace` fix for `Android Gradle Plugin` versions < 7
## 0.3.1
* Fix `namespace` for `Android Gradle Plugin` versions < 7
## 0.3.0
* Add `namespace` for compatibility with `Android Gradle Plugin 8`
* Update `Gradle` version
## 0.2.0
* Update `Gradle` version
* Update `Android Gradle plugin` version
* Update `Kotlin` version
* Add Gradle wrapper files
* Update `pubspec.lock` of example (`url: "https://pub.dev"`)
## 0.1.3+1
* Improve readme: Add explanation for connection of `Android ID` and signing key
## 0.1.3
* Add building of example app to GitHub workflows
* Remove unneeded code
## 0.1.2
* Add `Flutter test` for example app to workflows
## 0.1.1+1
* Update readme
## 0.1.1
* Update example app dependencies
## 0.1.0
* Rename Android `applicationId`
* Recreate `android` folder from template
## 0.0.8
* Add more GitHub Actions for checking code quality
* Update kotlin_version from 1.6.10 to 1.7.20
* Update gradle from 7.1.2 to 7.3.1
* Add dependabot
## 0.0.7
* Add Google Play information to README.
## 0.0.6
* Lower minimal sdk version to `2.12.0`.
## 0.0.5
* Documentation typo fix.
## 0.0.4
* Small improvements.
## 0.0.3
* Improve example.
## 0.0.2
* Enhance description to get full pub.dev score.
## 0.0.1
* Initial release.
-29
View File
@@ -1,29 +0,0 @@
BSD 3-Clause License
Copyright (c) 2022, Joachim Nohl
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-47
View File
@@ -1,47 +0,0 @@
# android_id
[![Flutter Community: android_id](https://fluttercommunity.dev/_github/header/android_id)](https://github.com/fluttercommunity/community)
![Pub Version](https://img.shields.io/pub/v/android_id)
A Flutter plugin for retrieving the Android ID.
## Getting started
* Add plugin to pubspec.yaml
* Use it in your code (see examples of all methods below)
## Usage
```dart
const _androidIdPlugin = AndroidId();
final String? androidId = await _androidIdPlugin.getId();
```
## Important
Please note that on `Android 8` and above, the `Android ID` is not unique per device, but also per signing key the app was built with:
<blockquote>
On Android 8.0 (API level 26) and higher versions of the platform, a 64-bit number (expressed as a hexadecimal string), <b>unique to each combination of app-signing key, user, and device.</b>
The value may change if a factory reset is performed on the device or if an APK signing key changes.
</blockquote>
[Android API reference](https://developer.android.com/reference/android/provider/Settings.Secure#ANDROID_ID)
[Stack Overflow explanation](https://stackoverflow.com/a/43393373)
## Google Play
Before using this plugin in your app, make sure to follow Google Play guidelines.
For example [here](https://support.google.com/googleplay/android-developer/answer/6048248#zippy=%2Cpersistent-identifiers-including-android-id):
<blockquote>
<b>Persistent identifiers, including Android ID</b>
Use for non-advertising purposes<br>
You can use persistent identifiers as long as you have a [privacy policy](https://support.google.com/googleplay/android-developer/answer/9859455) and handle the data in accordance with the [Developer Distribution Agreement](https://play.google.com/about/developer-distribution-agreement.html#use) and all applicable privacy laws in the areas where you make your app available.
</blockquote>
@@ -1 +0,0 @@
include: package:flutter_lints/flutter.yaml
-9
View File
@@ -1,9 +0,0 @@
*.iml
.gradle
/local.properties
/.idea/workspace.xml
/.idea/libraries
.DS_Store
/build
/captures
.cxx
-74
View File
@@ -1,74 +0,0 @@
group = "dev.fluttercommunity.android_id"
version = "1.0-SNAPSHOT"
buildscript {
ext.kotlin_version = "2.0.0"
repositories {
google()
mavenCentral()
maven{url'https://repo1.maven.org/maven2/'}
}
dependencies {
classpath("com.android.tools.build:gradle:8.1.4")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
}
}
allprojects {
repositories {
google()
mavenCentral()
maven{url'https://repo1.maven.org/maven2/'}
}
}
apply plugin: "com.android.library"
apply plugin: "kotlin-android"
android {
if (project.android.hasProperty("namespace")) {
namespace = "dev.fluttercommunity.android_id"
}
compileSdk = 34
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
sourceSets {
main.java.srcDirs += "src/main/kotlin"
test.java.srcDirs += "src/test/kotlin"
}
defaultConfig {
minSdk = 21
}
dependencies {
testImplementation("org.jetbrains.kotlin:kotlin-test")
testImplementation("org.mockito:mockito-core:5.12.0")
}
testOptions {
unitTests.all {
useJUnitPlatform()
testLogging {
events "passed", "skipped", "failed", "standardOut", "standardError"
outputs.upToDateWhen {false}
showStandardStreams = true
}
}
}
}
dependencies {
// implementation 'com.umeng.umsdk:common:9.7.7'// 必选
// implementation 'com.umeng.umsdk:asms:1.8.3'// 必选
}
@@ -1 +0,0 @@
rootProject.name = 'android_id'
@@ -1,8 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="dev.fluttercommunity.android_id">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_PHONE_STATE"/>
<uses-permission android:name="android.permission.READ_PRIVILEGED_PHONE_STATE"
tools:ignore="ProtectedPermissions" />
</manifest>
@@ -1,279 +0,0 @@
package dev.fluttercommunity.android_id
import android.annotation.SuppressLint
import android.content.ContentResolver
import android.provider.Settings
import androidx.annotation.NonNull
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
import android.content.Context
import android.net.wifi.WifiInfo
import android.net.wifi.WifiManager
import android.os.Build
import android.text.TextUtils
import androidx.core.app.ActivityCompat
import java.io.IOException
import java.io.InputStreamReader
import java.io.LineNumberReader
import java.net.NetworkInterface
import java.util.Collections
import java.util.List
import java.util.ArrayList
import java.util.Locale
import android.content.pm.PackageManager
import android.telephony.TelephonyManager
//import com.umeng.commonsdk.UMConfigure
/** AndroidIdPlugin */
class AndroidIdPlugin : FlutterPlugin, MethodCallHandler {
/// The MethodChannel that will the communication between Flutter and native Android
///
/// This local reference serves to register the plugin with the Flutter Engine and unregister it
/// when the Flutter Engine is detached from the Activity
private lateinit var channel: MethodChannel
private lateinit var contentResolver: ContentResolver
private lateinit var context: Context
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
contentResolver = flutterPluginBinding.applicationContext.contentResolver
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "android_id")
channel.setMethodCallHandler(this)
context = flutterPluginBinding.getApplicationContext()
}
override fun onMethodCall(call: MethodCall, result: Result) {
when (call.method) {
"getId" -> {
try {
result.success(getAndroidId())
} catch (e: Exception) {
result.success("")
// result.error("ERROR_GETTING_ID", "Failed to get Android ID", e.localizedMessage)
}
}
"getImei" -> {
try {
result.success(getImei())
} catch (e: Exception) {
result.success("")
// result.error("ERROR_GETTING_ID", "Failed to get Imei ", e.localizedMessage)
}
}
"getMacAddress" -> {
try {
result.success(getMacAddress())
} catch (e: Exception) {
result.success("")
// result.error("ERROR_GETTING_ID", "Failed to get MacAddress", e.localizedMessage)
}
}
// "getOaid" -> {
// try {
// UMConfigure.getOaid(context) { p0 ->
// if (p0 != null) {
// result.success(p0)
// } else {
// result.success("")
// }
// }
// } catch (e: Exception) {
// result.success("")
//// result.error("ERROR_GETTING_ID", "Failed to get MacAddress", e.localizedMessage)
// }
// }
"checkRoot" -> {
try {
result.success(checkRoot())
} catch (e: Exception) {
result.success(false)
// result.error("ERROR_GETTING_ID", "Failed to get MacAddress", e.localizedMessage)
}
}
"checkIsEmulator" -> {
try {
result.success(checkIsEmulator())
} catch (e: Exception) {
result.success(false)
// result.error("ERROR_GETTING_ID", "Failed to get MacAddress", e.localizedMessage)
}
}
else -> result.notImplemented()
}
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
}
// Fetch the Android ID while suppressing lint warning about hardware IDs
@SuppressLint("HardwareIds")
private fun getAndroidId(): String {
return Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID)
}
private fun getImei(): String? {
val tm: TelephonyManager =
context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
var IMEIStr = ""
if (ActivityCompat.checkSelfPermission(
context,
android.Manifest.permission.READ_PHONE_STATE
) !== PackageManager.PERMISSION_GRANTED
) {
} else {
try {
IMEIStr = tm.getDeviceId()
if (TextUtils.isEmpty(IMEIStr)) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
IMEIStr = tm.getImei()
}
}
} catch (e: java.lang.Exception) {
}
}
return IMEIStr
}
private fun getMacAddress(): String? {
var macNo = MacUtil().getMac(context)
return macNo
}
private fun checkRoot(): Boolean {
return false
}
private fun checkIsEmulator(): Boolean {
return false
}
}
class MacUtil {
/**
* Android 6.0 之前(不包括6.0)获取mac地址
* 必须的权限 <uses-permission android:name="android.permission.ACCESS_WIFI_STATE"></uses-permission>
*
* @param context * @return
*/
@SuppressLint("MissingPermission")
fun getMacDefault(context: Context?): String? {
var mac = ""
if (context == null) {
return mac
}
val wifi: WifiManager =
context.getApplicationContext().getSystemService(Context.WIFI_SERVICE) as WifiManager
var info: WifiInfo? = null
try {
info = wifi.getConnectionInfo()
} catch (e: java.lang.Exception) {
e.printStackTrace()
}
if (info == null) {
return null
}
mac = info.getMacAddress()
if (!TextUtils.isEmpty(mac)) {
mac = mac.uppercase()
}
return mac
}
val macAddress: String?
/**
* Android 6.0-Android 7.0 获取mac地址
*/
get() {
var macSerial: String? = null
var str = ""
try {
val pp: java.lang.Process =
java.lang.Runtime.getRuntime().exec("cat/sys/class/net/wlan0/address")
val ir: InputStreamReader = InputStreamReader(pp.getInputStream())
val input: LineNumberReader = LineNumberReader(ir)
while (null != str) {
str = input.readLine()
if (str != null) {
macSerial = str.trim { it <= ' ' } //去空格
break
}
}
} catch (ex: IOException) {
// 赋予默认值
ex.printStackTrace()
}
return macSerial
}
/**
* 获取mac地址(适配所有Android版本)
*
* @return
*/
fun getMac(context: Context?): String? {
var mac: String? = ""
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
mac = getMacDefault(context)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
mac = macAddress
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
mac = macFromHardware
}
return mac
}
companion object {
private val macFromHardware: String?
/**
* Android 7.0之后获取Mac地址
* 遍历循环所有的网络接口,找到接口是 wlan0
* 必须的权限 <uses-permission android:name="android.permission.INTERNET"></uses-permission>
*
* @return
*/
get() {
try {
val all: ArrayList<NetworkInterface> =
Collections.list<NetworkInterface>(NetworkInterface.getNetworkInterfaces())
for (nif in all) {
if (!nif.getName().equals("wlan0", ignoreCase = true)) {
continue
}
val macBytes: ByteArray = nif.getHardwareAddress() ?: return null
val res1: java.lang.StringBuilder = java.lang.StringBuilder()
for (b in macBytes) {
res1.append(String.format("%02X:", b))
}
if (res1.length > 0) {
res1.deleteCharAt(res1.length - 1)
}
return res1.toString()
}
} catch (e: java.lang.Exception) {
e.printStackTrace()
}
return null
}
}
}
-58
View File
@@ -1,58 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// The plugin class for retrieving the Android ID.
class AndroidId {
const AndroidId();
/// The method channel used to interact with the native platform.
static const _methodChannel = MethodChannel('android_id');
/// Calls the native method to retrieve the Android ID.
Future<String?> getId() async {
final isAndroid =
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
if (!isAndroid) return null;
return _methodChannel.invokeMethod<String?>('getId');
}
Future<String?> getImei() async {
final isAndroid =
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
if (!isAndroid) return null;
return _methodChannel.invokeMethod<String?>('getImei');
}
Future<String?> getMaAddress() async {
final isAndroid =
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
if (!isAndroid) return null;
return _methodChannel.invokeMethod<String?>('getMacAddress');
}
// Future<String?> getOaid() async {
// final isAndroid =
// !kIsWeb && defaultTargetPlatform == TargetPlatform.android;
// if (!isAndroid) return null;
//
// return _methodChannel.invokeMethod<String?>('getOaid');
// }
Future<bool?> isRoot() async {
final isAndroid =
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
if (!isAndroid) return null;
return _methodChannel.invokeMethod<bool?>('checkRoot');
}
Future<bool?> isEmulator() async {
final isAndroid =
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
if (!isAndroid) return null;
return _methodChannel.invokeMethod<bool?>('checkIsEmulator');
}
}
-36
View File
@@ -1,36 +0,0 @@
name: android_id
description: A Flutter plugin for getting the Android ID. Only for Android.
repository: https://github.com/fluttercommunity/android_id
issue_tracker: https://github.com/fluttercommunity/android_id/issues
maintainer: Joachim Nohl (@nohli)
topics:
- androidid
- device
- identifier
- android
- native
version: 0.4.0
environment:
sdk: '>=2.12.0 <4.0.0'
flutter: '>=2.0.0'
dependencies:
flutter:
sdk: flutter
dev_dependencies:
flutter_lints: '>=2.0.0 <4.0.0'
flutter_test:
sdk: flutter
flutter:
plugin:
platforms:
android:
package: dev.fluttercommunity.android_id
pluginClass: AndroidIdPlugin
@@ -1,26 +0,0 @@
import 'package:android_id/android_id.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
const plugin = AndroidId();
const channel = MethodChannel('android_id');
TestWidgetsFlutterBinding.ensureInitialized();
debugDefaultTargetPlatformOverride = TargetPlatform.android;
setUp(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (methodCall) async => '42');
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
});
test('getAndroidId', () async {
expect(await plugin.getId(), '42');
});
}
-26
View File
@@ -1,26 +0,0 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
.vscode/
# Flutter/Dart/Pub related
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
/pubspec.lock
**/doc/api/
.dart_tool/
.packages
build/
-3
View File
@@ -1,3 +0,0 @@
## 0.0.1
* Initial release.
-29
View File
@@ -1,29 +0,0 @@
BSD 3-Clause License
Copyright (c) 2022, Joachim Nohl
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-15
View File
@@ -1,15 +0,0 @@
# flutter_oaid
A new Flutter plugin project.
## Getting Started
This project is a starting point for a Flutter
[plug-in package](https://flutter.dev/developing-packages/),
a specialized package that includes platform-specific implementation code for
Android and/or iOS.
For help getting started with Flutter development, view the
[online documentation](https://flutter.dev/docs), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
@@ -1 +0,0 @@
include: package:flutter_lints/flutter.yaml
-9
View File
@@ -1,9 +0,0 @@
*.iml
.gradle
/local.properties
/.idea/workspace.xml
/.idea/libraries
.DS_Store
/build
/captures
.cxx
@@ -1,74 +0,0 @@
group = "dev.fluttercommunity.android_oaid"
version = "1.0-SNAPSHOT"
buildscript {
ext.kotlin_version = "2.0.0"
repositories {
google()
mavenCentral()
maven{url'https://repo1.maven.org/maven2/'}
}
dependencies {
classpath("com.android.tools.build:gradle:8.1.4")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
}
}
allprojects {
repositories {
google()
mavenCentral()
maven{url'https://repo1.maven.org/maven2/'}
}
}
apply plugin: "com.android.library"
apply plugin: "kotlin-android"
android {
if (project.android.hasProperty("namespace")) {
namespace = "dev.fluttercommunity.android_oaid"
}
compileSdk = 34
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
sourceSets {
main.java.srcDirs += "src/main/kotlin"
test.java.srcDirs += "src/test/kotlin"
}
defaultConfig {
minSdk = 21
}
dependencies {
testImplementation("org.jetbrains.kotlin:kotlin-test")
testImplementation("org.mockito:mockito-core:5.12.0")
}
testOptions {
unitTests.all {
useJUnitPlatform()
testLogging {
events "passed", "skipped", "failed", "standardOut", "standardError"
outputs.upToDateWhen {false}
showStandardStreams = true
}
}
}
}
dependencies {
implementation 'com.umeng.umsdk:common:9.7.7'// 必选
implementation 'com.umeng.umsdk:asms:1.8.3'// 必选
}
@@ -1 +0,0 @@
rootProject.name = 'android_oaid'
@@ -1,5 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="dev.fluttercommunity.android_oaid">
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
@@ -1,59 +0,0 @@
package dev.fluttercommunity.android_oaid
import android.content.ContentResolver
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
import android.content.Context
import android.os.Handler
import android.os.Looper
import com.umeng.commonsdk.UMConfigure
/** AndroidOaidPlugin */
class AndroidOaidPlugin : FlutterPlugin, MethodCallHandler {
private lateinit var channel: MethodChannel
private lateinit var contentResolver: ContentResolver
private lateinit var context: Context
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
contentResolver = flutterPluginBinding.applicationContext.contentResolver
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "android_oaid")
channel.setMethodCallHandler(this)
context = flutterPluginBinding.getApplicationContext()
}
override fun onMethodCall(call: MethodCall, result: Result) {
when (call.method) {
"getOaid" -> {
try {
UMConfigure.getOaid(context) { p0 ->
Handler(Looper.getMainLooper()).post {
if (p0 != null) {
result.success(p0)
} else {
result.success("")
}
}
}
} catch (e: Exception) {
Handler(Looper.getMainLooper()).post {
result.success("")
}
}
}
else -> result.notImplemented()
}
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
}
}
@@ -1,18 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
class AndroidOaidId {
const AndroidOaidId();
static const _methodChannel = MethodChannel('android_oaid');
Future<String?> getOaid() async {
final isAndroid =
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
if (!isAndroid) return null;
return _methodChannel.invokeMethod<String?>('getOaid');
}
}
-26
View File
@@ -1,26 +0,0 @@
name: android_oaid
description: A Flutter plugin for getting the Android OAID. Only for Android.
maintainer: Joachim Nohl (@nohli)
version: 0.4.0
environment:
sdk: '>=2.12.0 <4.0.0'
flutter: '>=2.0.0'
dependencies:
flutter:
sdk: flutter
dev_dependencies:
flutter_lints: '>=2.0.0 <4.0.0'
flutter_test:
sdk: flutter
flutter:
plugin:
platforms:
android:
package: dev.fluttercommunity.android_oaid
pluginClass: AndroidOaidPlugin
@@ -1,26 +0,0 @@
import 'package:android_oaid/android_oaid.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
const plugin = AndroidOaidId();
const channel = MethodChannel('android_oaid');
TestWidgetsFlutterBinding.ensureInitialized();
debugDefaultTargetPlatformOverride = TargetPlatform.android;
setUp(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (methodCall) async => '42');
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
});
test('getAndroidId', () async {
expect(await plugin.getOaid(), '42');
});
}
-29
View File
@@ -1,29 +0,0 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
/pubspec.lock
**/doc/api/
.dart_tool/
build/
-30
View File
@@ -1,30 +0,0 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "abb292a07e20d696c4568099f918f6c5f330e6b0"
channel: "stable"
project_type: plugin
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: abb292a07e20d696c4568099f918f6c5f330e6b0
base_revision: abb292a07e20d696c4568099f918f6c5f330e6b0
- platform: ios
create_revision: abb292a07e20d696c4568099f918f6c5f330e6b0
base_revision: abb292a07e20d696c4568099f918f6c5f330e6b0
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
-3
View File
@@ -1,3 +0,0 @@
## 0.0.1
* TODO: Describe initial release.
-1
View File
@@ -1 +0,0 @@
TODO: Add your license here.
Loaded 100 of 171 files, more files were not shown because too many files have changed in this diff. Show more