feat: 完成智能电网电工端 Flutter 应用初始化
创建完整的 Flutter 项目结构,包含 Android 和 iOS 平台配置、核心网络层、路由、状态管理及业务模块(订单、收益、认证),适配原 uni-app 项目功能逻辑
This commit is contained in:
commit
56830e94ec
108 files changed
+9272
No files matched your search
@@ -0,0 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 全局 Key:供非 Widget 层(拦截器、工具函数)访问 Navigator / ScaffoldMessenger。
|
||||
abstract final class AppKeys {
|
||||
static final rootNavigator = GlobalKey<NavigatorState>();
|
||||
static final scaffoldMessenger = GlobalKey<ScaffoldMessengerState>();
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/// 运行期配置。
|
||||
///
|
||||
/// 与 uni-app 版 `utils/request.ts` 的地址策略一致:App 端直连后端。
|
||||
/// 真机联调时用 `--dart-define=API_BASE_URL=http://<局域网IP>:7001/api` 覆盖默认值。
|
||||
///
|
||||
/// 登录页「服务端设置」(仅调试版可见)保存的 IP+端口优先级更高,
|
||||
/// 解析顺序见 [resolveBaseUrl]。
|
||||
abstract final class Env {
|
||||
/// 编译期默认地址,作为兜底方案。
|
||||
static const String apiBaseUrl = String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: 'http://192.168.3.29:7001/api',
|
||||
);
|
||||
|
||||
static final Uri _default = Uri.parse(apiBaseUrl);
|
||||
|
||||
/// 默认主机,取自 [apiBaseUrl]。
|
||||
static String get defaultHost => _default.host;
|
||||
|
||||
/// 默认端口,取自 [apiBaseUrl],未显式指定时回落 7007。
|
||||
static int get defaultPort => _default.hasPort ? _default.port : 7001;
|
||||
|
||||
/// 由 IP + 端口拼接 Base URL;协议与路径沿用 [apiBaseUrl],
|
||||
/// 这样 `--dart-define` 传入自定义路径时也能保持一致。
|
||||
static String buildBaseUrl(String host, String port) {
|
||||
final h = host.trim();
|
||||
final p = port.trim();
|
||||
final resolvedPort = p.isEmpty ? '$defaultPort' : p;
|
||||
return '${_default.scheme}://$h:$resolvedPort${_default.path}';
|
||||
}
|
||||
|
||||
/// 当前生效的 Base URL:登录页保存值优先,其次编译期默认值。
|
||||
static String resolveBaseUrl({String host = '', String port = ''}) =>
|
||||
host.trim().isEmpty ? apiBaseUrl : buildBaseUrl(host, port);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../storage/token_storage.dart';
|
||||
import 'api_response.dart';
|
||||
|
||||
/// 统一请求封装,等价于 uni-app 版 `utils/request.ts`:
|
||||
/// - 自动注入 `Authorization: Bearer <token>`
|
||||
/// - 解析 `{ code, message, data }`,非 200 也照常返回给调用方判断
|
||||
/// - 401 直接判定会话失效并广播给会话层,由会话层清空本地登录态回到登录页。
|
||||
///
|
||||
/// 注意:电工端后端未提供 refresh 接口(与管理端 `auth.controller` 不同),
|
||||
/// 因此这里不做静默续期,过期即重新登录。
|
||||
class ApiClient {
|
||||
ApiClient({required TokenStorage storage, required String baseUrl, Dio? dio})
|
||||
: _storage = storage,
|
||||
_dio =
|
||||
dio ??
|
||||
Dio(
|
||||
BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
sendTimeout: const Duration(seconds: 30),
|
||||
// 自行判断业务 code,不因 HTTP 状态码抛异常
|
||||
validateStatus: (_) => true,
|
||||
),
|
||||
);
|
||||
|
||||
final TokenStorage _storage;
|
||||
final Dio _dio;
|
||||
|
||||
final _unauthorizedController = StreamController<void>.broadcast();
|
||||
|
||||
/// 会话失效事件:401 时触发,由会话层订阅后跳转登录页。
|
||||
Stream<void> get onUnauthorized => _unauthorizedController.stream;
|
||||
|
||||
Future<ApiResponse<T>> get<T>(
|
||||
String url, {
|
||||
Map<String, dynamic>? query,
|
||||
T Function(dynamic data)? parse,
|
||||
bool needAuth = true,
|
||||
}) => _send<T>(
|
||||
'GET',
|
||||
url,
|
||||
query: _compact(query),
|
||||
parse: parse,
|
||||
needAuth: needAuth,
|
||||
);
|
||||
|
||||
Future<ApiResponse<T>> post<T>(
|
||||
String url, {
|
||||
Object? body,
|
||||
T Function(dynamic data)? parse,
|
||||
bool needAuth = true,
|
||||
}) => _send<T>('POST', url, body: body, parse: parse, needAuth: needAuth);
|
||||
|
||||
Future<ApiResponse<T>> put<T>(
|
||||
String url, {
|
||||
Object? body,
|
||||
T Function(dynamic data)? parse,
|
||||
bool needAuth = true,
|
||||
}) => _send<T>('PUT', url, body: body, parse: parse, needAuth: needAuth);
|
||||
|
||||
void dispose() => _unauthorizedController.close();
|
||||
|
||||
// ---- 内部实现 ----
|
||||
|
||||
Future<ApiResponse<T>> _send<T>(
|
||||
String method,
|
||||
String url, {
|
||||
Map<String, dynamic>? query,
|
||||
Object? body,
|
||||
T Function(dynamic data)? parse,
|
||||
required bool needAuth,
|
||||
}) async {
|
||||
late Response<dynamic> res;
|
||||
try {
|
||||
res = await _dio.request<dynamic>(
|
||||
url,
|
||||
data: body,
|
||||
queryParameters: query,
|
||||
options: Options(
|
||||
method: method,
|
||||
headers: needAuth ? _authHeader() : null,
|
||||
),
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw ApiException(e.message ?? '网络请求失败');
|
||||
}
|
||||
|
||||
final response = ApiResponse<T>.fromJson(_asMap(res.data), parse);
|
||||
|
||||
if (response.code == 401) {
|
||||
_unauthorizedController.add(null);
|
||||
throw const ApiException('登录已失效,请重新登录', code: 401);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _authHeader() {
|
||||
final token = _storage.accessToken;
|
||||
return token.isEmpty ? const {} : {'Authorization': 'Bearer $token'};
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _asMap(dynamic data) {
|
||||
if (data is Map) return data.cast<String, dynamic>();
|
||||
if (data is String && data.isNotEmpty) {
|
||||
try {
|
||||
final decoded = jsonDecode(data);
|
||||
if (decoded is Map) return decoded.cast<String, dynamic>();
|
||||
} catch (_) {}
|
||||
}
|
||||
return const {'code': -1, 'message': '响应解析失败'};
|
||||
}
|
||||
|
||||
/// 去掉 null / 空串查询条件。
|
||||
/// uni-app 会把 undefined 序列化成空串(如 `status=`),后端对空串查询条件会 500,
|
||||
/// 因此这里同样只在有值时才拼接参数。
|
||||
static Map<String, dynamic>? _compact(Map<String, dynamic>? src) {
|
||||
if (src == null || src.isEmpty) return null;
|
||||
final out = <String, dynamic>{};
|
||||
src.forEach((k, v) {
|
||||
if (v == null) return;
|
||||
if (v is String && v.isEmpty) return;
|
||||
out[k] = v;
|
||||
});
|
||||
return out.isEmpty ? null : out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/// 后端统一响应体:`{ code, message, data }`。
|
||||
class ApiResponse<T> {
|
||||
const ApiResponse({required this.code, required this.message, this.data});
|
||||
|
||||
final int code;
|
||||
final String message;
|
||||
final T? data;
|
||||
|
||||
bool get isOk => code == 200;
|
||||
|
||||
factory ApiResponse.fromJson(
|
||||
Map<String, dynamic> json,
|
||||
T Function(dynamic data)? parse,
|
||||
) {
|
||||
final raw = json['data'];
|
||||
return ApiResponse<T>(
|
||||
code: (json['code'] as num?)?.toInt() ?? -1,
|
||||
message: (json['message'] as String?) ?? '',
|
||||
data: (raw == null || parse == null) ? null : parse(raw),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 网络/协议层异常。调用方捕获后用 `message` 提示用户。
|
||||
class ApiException implements Exception {
|
||||
const ApiException(this.message, {this.code});
|
||||
|
||||
final String message;
|
||||
final int? code;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../features/auth/application/session_controller.dart';
|
||||
import '../features/notice/data/notice_api.dart';
|
||||
import 'env.dart';
|
||||
import 'network/api_client.dart';
|
||||
import 'network/api_response.dart';
|
||||
import 'storage/token_storage.dart';
|
||||
|
||||
/// 在 `main()` 中读取 SharedPreferences 后通过 override 注入。
|
||||
final tokenStorageProvider = Provider<TokenStorage>(
|
||||
(ref) => throw UnimplementedError('tokenStorageProvider 必须在 ProviderScope 中 override'),
|
||||
);
|
||||
|
||||
/// 登录页「服务端设置」保存的主机与端口;空串表示未覆盖,走编译期默认值。
|
||||
typedef ServerAddress = ({String host, String port});
|
||||
|
||||
/// 服务端地址状态。登录页保存/重置后通知 [apiClientProvider] 重建客户端,
|
||||
/// 因此无需重启 App 即可切换后端。
|
||||
class ServerAddressNotifier extends Notifier<ServerAddress> {
|
||||
@override
|
||||
ServerAddress build() {
|
||||
final storage = ref.watch(tokenStorageProvider);
|
||||
return (host: storage.serverHost, port: storage.serverPort);
|
||||
}
|
||||
|
||||
Future<void> save(String host, String port) async {
|
||||
await ref.read(tokenStorageProvider).setServerAddress(host, port);
|
||||
state = (host: host, port: port);
|
||||
}
|
||||
|
||||
Future<void> reset() async {
|
||||
await ref.read(tokenStorageProvider).clearServerAddress();
|
||||
state = (host: '', port: '');
|
||||
}
|
||||
}
|
||||
|
||||
final serverAddressProvider =
|
||||
NotifierProvider<ServerAddressNotifier, ServerAddress>(
|
||||
ServerAddressNotifier.new,
|
||||
);
|
||||
|
||||
/// 当前生效的 API Base URL。
|
||||
final apiBaseUrlProvider = Provider<String>(
|
||||
(ref) => Env.resolveBaseUrl(
|
||||
host: ref.watch(serverAddressProvider).host,
|
||||
port: ref.watch(serverAddressProvider).port,
|
||||
),
|
||||
);
|
||||
|
||||
final apiClientProvider = Provider<ApiClient>((ref) {
|
||||
final client = ApiClient(
|
||||
storage: ref.watch(tokenStorageProvider),
|
||||
baseUrl: ref.watch(apiBaseUrlProvider),
|
||||
);
|
||||
ref.onDispose(client.dispose);
|
||||
return client;
|
||||
});
|
||||
|
||||
/// 站内消息中心接口。
|
||||
final noticeApiProvider = Provider<NoticeApi>(
|
||||
(ref) => NoticeApi(ref.watch(apiClientProvider)),
|
||||
);
|
||||
|
||||
/// 未读消息数。登录态下每 60s 轮询一次,登出立即停止并归零;
|
||||
/// 三处顶栏铃铛与消息中心页共用,标记已读后调用 [noticeUnreadCountProvider]
|
||||
/// 的 `refresh()` 立即刷新。
|
||||
final noticeUnreadCountProvider =
|
||||
NotifierProvider<NoticeUnreadCountNotifier, int>(
|
||||
NoticeUnreadCountNotifier.new,
|
||||
);
|
||||
|
||||
class NoticeUnreadCountNotifier extends Notifier<int> {
|
||||
static const _interval = Duration(seconds: 60);
|
||||
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
int build() {
|
||||
// 登录态变化时重建:先停掉上一轮轮询,避免重复计时器。
|
||||
_stop();
|
||||
|
||||
final loggedIn = ref.watch(sessionProvider.select((s) => s.isLoggedIn));
|
||||
if (!loggedIn) return 0;
|
||||
|
||||
ref.onDispose(_stop);
|
||||
_timer = Timer.periodic(_interval, (_) => unawaited(refresh()));
|
||||
unawaited(refresh());
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// 立即拉取一次未读数(进入消息中心 / 单条或全部已读后调用)。
|
||||
Future<void> refresh() async {
|
||||
try {
|
||||
final res = await ref.read(noticeApiProvider).unreadCount();
|
||||
// 请求期间若已登出,丢弃结果,避免残留红点。
|
||||
if (!ref.read(sessionProvider).isLoggedIn) return;
|
||||
if (res.isOk && res.data != null) state = res.data!;
|
||||
} on ApiException {
|
||||
// 轮询失败保持上一次的值,不打断 UI。
|
||||
}
|
||||
}
|
||||
|
||||
void _stop() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// 本地持久化。键名与 uni-app 版 `uni.getStorageSync` 使用的键保持一致,
|
||||
/// 便于两端在真机上迁移/对拍时理解同一份数据。
|
||||
class TokenStorage {
|
||||
TokenStorage(this._prefs);
|
||||
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
static const _kAccessToken = 'accessToken';
|
||||
static const _kUserInfo = 'userInfo';
|
||||
static const _kServerHost = 'serverHost';
|
||||
static const _kServerPort = 'serverPort';
|
||||
|
||||
static Future<TokenStorage> open() async =>
|
||||
TokenStorage(await SharedPreferences.getInstance());
|
||||
|
||||
String get accessToken => _prefs.getString(_kAccessToken) ?? '';
|
||||
|
||||
Map<String, dynamic>? get userInfo {
|
||||
final raw = _prefs.getString(_kUserInfo);
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
try {
|
||||
return jsonDecode(raw) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setAccessToken(String token) =>
|
||||
_prefs.setString(_kAccessToken, token);
|
||||
|
||||
Future<void> setUserInfo(Map<String, dynamic> info) =>
|
||||
_prefs.setString(_kUserInfo, jsonEncode(info));
|
||||
|
||||
/// 登录页「服务端设置」保存的 IP 与端口,空串表示未覆盖、走编译期默认值。
|
||||
String get serverHost => _prefs.getString(_kServerHost) ?? '';
|
||||
|
||||
String get serverPort => _prefs.getString(_kServerPort) ?? '';
|
||||
|
||||
Future<void> setServerAddress(String host, String port) async {
|
||||
await _prefs.setString(_kServerHost, host);
|
||||
await _prefs.setString(_kServerPort, port);
|
||||
}
|
||||
|
||||
Future<void> clearServerAddress() async {
|
||||
await _prefs.remove(_kServerHost);
|
||||
await _prefs.remove(_kServerPort);
|
||||
}
|
||||
|
||||
Future<void> clearSession() async {
|
||||
await _prefs.remove(_kAccessToken);
|
||||
await _prefs.remove(_kUserInfo);
|
||||
}
|
||||
|
||||
bool get hasSession => accessToken.isNotEmpty;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 设计令牌 - 色彩。
|
||||
/// 逐项对应 uni-app 版 `src/uni.scss` 中的 `$color-*` 变量。
|
||||
abstract final class AppColors {
|
||||
// Primary
|
||||
/// 品牌主色(源 `$primary-container`)。源页面绝大多数蓝色元素用它。
|
||||
static const primary = Color(0xFF0052FF);
|
||||
|
||||
/// 深一档的品牌色(源 `$primary`),用于余额卡渐变起始色。
|
||||
static const primaryDeep = Color(0xFF003EC7);
|
||||
static const onPrimary = Color(0xFFFFFFFF);
|
||||
static const onPrimaryContainer = Color(0xFFDFE3FF);
|
||||
static const primaryFixed = Color(0xFFDDE1FF);
|
||||
static const primaryFixedDim = Color(0xFFB7C4FF);
|
||||
static const onPrimaryFixed = Color(0xFF001452);
|
||||
static const onPrimaryFixedVariant = Color(0xFF0038B6);
|
||||
|
||||
// Secondary
|
||||
static const secondary = Color(0xFF4C4ACA);
|
||||
static const secondaryContainer = Color(0xFF6664E4);
|
||||
static const onSecondary = Color(0xFFFFFFFF);
|
||||
static const onSecondaryContainer = Color(0xFFFFFBFF);
|
||||
static const secondaryFixed = Color(0xFFE2DFFF);
|
||||
static const secondaryFixedDim = Color(0xFFC2C1FF);
|
||||
static const onSecondaryFixed = Color(0xFF0C006A);
|
||||
static const onSecondaryFixedVariant = Color(0xFF3631B4);
|
||||
|
||||
// Tertiary
|
||||
static const tertiary = Color(0xFF952200);
|
||||
static const tertiaryContainer = Color(0xFFBF3003);
|
||||
static const onTertiary = Color(0xFFFFFFFF);
|
||||
static const onTertiaryContainer = Color(0xFFFFDDD5);
|
||||
static const tertiaryFixed = Color(0xFFFFDBD2);
|
||||
static const tertiaryFixedDim = Color(0xFFFFB4A1);
|
||||
static const onTertiaryFixed = Color(0xFF3C0800);
|
||||
static const onTertiaryFixedVariant = Color(0xFF891E00);
|
||||
|
||||
// Error
|
||||
static const error = Color(0xFFBA1A1A);
|
||||
static const errorContainer = Color(0xFFFFDAD6);
|
||||
static const onError = Color(0xFFFFFFFF);
|
||||
static const onErrorContainer = Color(0xFF93000A);
|
||||
|
||||
// Surface
|
||||
static const surface = Color(0xFFFAF9FE);
|
||||
static const surfaceBright = Color(0xFFFAF9FE);
|
||||
static const surfaceDim = Color(0xFFDAD9DF);
|
||||
static const surfaceContainerLowest = Color(0xFFFFFFFF);
|
||||
static const surfaceContainerLow = Color(0xFFF4F3F8);
|
||||
static const surfaceContainer = Color(0xFFEEEDF3);
|
||||
static const surfaceContainerHigh = Color(0xFFE9E7ED);
|
||||
static const surfaceContainerHighest = Color(0xFFE3E2E7);
|
||||
static const surfaceVariant = Color(0xFFE3E2E7);
|
||||
|
||||
// Background
|
||||
static const bgPage = Color(0xFFFAF9FE);
|
||||
static const background = Color(0xFFFAF9FE);
|
||||
|
||||
// Text
|
||||
static const onSurface = Color(0xFF1A1B1F);
|
||||
static const onSurfaceVariant = Color(0xFF434656);
|
||||
static const textMuted = Color(0xFF737688);
|
||||
static const textPlaceholder = Color(0xFFC3C5D9);
|
||||
|
||||
// Outline
|
||||
static const outline = Color(0xFF737688);
|
||||
static const outlineVariant = Color(0xFFC3C5D9);
|
||||
static const borderRule = Color(0xFFC3C5D9);
|
||||
|
||||
/// `rgba($outline-variant, 0.2)` —— 各页面 `.ios-card` / 分隔线用的浅描边。
|
||||
static const borderSoft = Color(0x33C3C5D9);
|
||||
|
||||
/// `.ios-blur` / `.custom-tab-bar` 的毛玻璃底色 rgba(250,249,254,0.85)
|
||||
static const headerGlass = Color(0xD9FAF9FE);
|
||||
|
||||
// Inverse
|
||||
static const inverseSurface = Color(0xFF2F3034);
|
||||
static const inverseOnSurface = Color(0xFFF1F0F5);
|
||||
static const inversePrimary = Color(0xFFB7C4FF);
|
||||
|
||||
// Accent
|
||||
static const accentSubtle = Color(0x140052FF);
|
||||
|
||||
// Status
|
||||
static const success = Color(0xFF34C759);
|
||||
static const star = Color(0xFFFF9500);
|
||||
static const statusWarn = Color(0xFFE0852B);
|
||||
static const statusError = Color(0xFFDC2626);
|
||||
|
||||
// 底部导航毛玻璃底:rgba(250,249,254,0.95)
|
||||
static const navGlass = Color(0xF2FAF9FE);
|
||||
}
|
||||
|
||||
/// chip 语义样式:背景色 + 前景色,对应各页面 `.chip-*` / 徽章配色。
|
||||
enum ChipTone { primary, secondary, urgent, warning, success, muted }
|
||||
|
||||
extension ChipToneStyle on ChipTone {
|
||||
Color get foreground => switch (this) {
|
||||
ChipTone.primary => AppColors.primary,
|
||||
ChipTone.secondary => AppColors.secondary,
|
||||
ChipTone.urgent => AppColors.tertiaryContainer,
|
||||
ChipTone.warning => AppColors.statusWarn,
|
||||
ChipTone.success => AppColors.success,
|
||||
ChipTone.muted => AppColors.onSurfaceVariant,
|
||||
};
|
||||
|
||||
Color get background => switch (this) {
|
||||
ChipTone.primary => AppColors.accentSubtle,
|
||||
ChipTone.secondary => const Color(0x144C4ACA),
|
||||
ChipTone.urgent => const Color(0x14BF3003),
|
||||
ChipTone.warning => const Color(0x14E0852B),
|
||||
ChipTone.success => const Color(0x1434C759),
|
||||
ChipTone.muted => AppColors.surfaceContainer,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:flutter/painting.dart';
|
||||
|
||||
/// 设计令牌 - 间距与圆角。对应 `uni.scss` 的 `$spacing-*` / `$rounded-*`。
|
||||
abstract final class AppSpacing {
|
||||
static const double base = 4;
|
||||
static const double xs = 8;
|
||||
static const double sm = 12;
|
||||
static const double md = 16;
|
||||
static const double lg = 24;
|
||||
static const double xl = 40;
|
||||
|
||||
/// `$spacing-margin-page` / `$spacing-gutter-grid` = 1rem
|
||||
static const double gutter = 16;
|
||||
}
|
||||
|
||||
abstract final class AppRadius {
|
||||
/// `$rounded-sm` = 0.25rem
|
||||
static const double sm = 4;
|
||||
|
||||
/// `$rounded-md` = 0.5rem
|
||||
static const double regular = 8;
|
||||
|
||||
/// `$rounded-lg` = 0.75rem
|
||||
static const double lg = 12;
|
||||
|
||||
/// `$rounded-xl` = 1rem
|
||||
static const double xl = 16;
|
||||
|
||||
/// 订单区 / 底部弹窗的 24px 大圆角
|
||||
static const double xxl = 24;
|
||||
|
||||
/// `$rounded-full`
|
||||
static const double full = 9999;
|
||||
}
|
||||
|
||||
abstract final class AppShadows {
|
||||
/// 全局 `.ios-card`:0 1px 3px rgba(0,0,0,0.04)
|
||||
static const card = [
|
||||
BoxShadow(color: Color(0x0A000000), blurRadius: 3, offset: Offset(0, 1)),
|
||||
];
|
||||
|
||||
/// 订单卡片:0 2px 8px rgba(0,0,0,0.04)
|
||||
static const orderCard = [
|
||||
BoxShadow(color: Color(0x0A000000), blurRadius: 8, offset: Offset(0, 2)),
|
||||
];
|
||||
|
||||
/// 顶栏下边框阴影:0 -1px 3px rgba(0,0,0,0.05)
|
||||
static const topBar = [
|
||||
BoxShadow(color: Color(0x0D000000), blurRadius: 3, offset: Offset(0, 1)),
|
||||
];
|
||||
|
||||
/// `@mixin ios-shadow`:0 2px 8px rgba(0,0,0,0.04)
|
||||
static const soft = [
|
||||
BoxShadow(color: Color(0x0A000000), blurRadius: 8, offset: Offset(0, 2)),
|
||||
];
|
||||
|
||||
/// 底部导航条:向上投影
|
||||
static const bottomNav = [
|
||||
BoxShadow(color: Color(0x14000000), blurRadius: 24, offset: Offset(0, -8)),
|
||||
];
|
||||
|
||||
/// 品牌主按钮发光:0 8px 20px rgba(0,82,255,0.28)
|
||||
static const primaryButton = [
|
||||
BoxShadow(color: Color(0x470052FF), blurRadius: 20, offset: Offset(0, 8)),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'app_colors.dart';
|
||||
|
||||
abstract final class AppTheme {
|
||||
/// 全平台统一使用 iOS 风格「新页面从右侧滑入」转场,与 uni-app 版
|
||||
/// `components/PageTransition.vue` 的表现对齐。
|
||||
static const _transitions = PageTransitionsTheme(
|
||||
builders: {
|
||||
TargetPlatform.android: CupertinoPageTransitionsBuilder(),
|
||||
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
|
||||
TargetPlatform.macOS: CupertinoPageTransitionsBuilder(),
|
||||
TargetPlatform.windows: CupertinoPageTransitionsBuilder(),
|
||||
TargetPlatform.linux: CupertinoPageTransitionsBuilder(),
|
||||
TargetPlatform.fuchsia: CupertinoPageTransitionsBuilder(),
|
||||
},
|
||||
);
|
||||
|
||||
static ThemeData build() {
|
||||
final base = ThemeData.light(useMaterial3: true);
|
||||
|
||||
return base.copyWith(
|
||||
scaffoldBackgroundColor: AppColors.bgPage,
|
||||
pageTransitionsTheme: _transitions,
|
||||
colorScheme: base.colorScheme.copyWith(
|
||||
primary: AppColors.primary,
|
||||
onPrimary: AppColors.onPrimary,
|
||||
secondary: AppColors.secondary,
|
||||
onSecondary: AppColors.onSecondary,
|
||||
tertiary: AppColors.tertiary,
|
||||
onTertiary: AppColors.onTertiary,
|
||||
error: AppColors.error,
|
||||
onError: AppColors.onError,
|
||||
surface: AppColors.surfaceContainerLowest,
|
||||
onSurface: AppColors.onSurface,
|
||||
outline: AppColors.outline,
|
||||
outlineVariant: AppColors.outlineVariant,
|
||||
),
|
||||
textTheme: base.textTheme.apply(
|
||||
bodyColor: AppColors.onSurface,
|
||||
displayColor: AppColors.onSurface,
|
||||
),
|
||||
dividerTheme: const DividerThemeData(
|
||||
color: AppColors.borderRule,
|
||||
thickness: 0.5,
|
||||
space: 1,
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: AppColors.surface,
|
||||
foregroundColor: AppColors.onSurface,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
centerTitle: true,
|
||||
systemOverlayStyle: SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.dark,
|
||||
statusBarBrightness: Brightness.light,
|
||||
),
|
||||
titleTextStyle: TextStyle(
|
||||
fontSize: 17,
|
||||
height: 24 / 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: AppColors.surfaceContainerLow,
|
||||
hintStyle: const TextStyle(color: AppColors.textPlaceholder, fontSize: 17),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.primary, width: 1.5),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.error),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.error, width: 1.5),
|
||||
),
|
||||
),
|
||||
splashFactory: InkSparkle.splashFactory,
|
||||
tooltipTheme: const TooltipThemeData(waitDuration: Duration(milliseconds: 400)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'app_colors.dart';
|
||||
|
||||
/// 设计令牌 - 字号 / 行高 / 字重。
|
||||
/// 对应 `uni.scss` 的 `$font-*` 与 `global.scss` 的 `.font-*` 工具类,
|
||||
/// 行高统一换算成 Flutter 的 `height`(行高 ÷ 字号)。
|
||||
abstract final class AppTypography {
|
||||
static const _sansFallback = <String>[
|
||||
'Inter',
|
||||
'PingFang SC',
|
||||
'Microsoft YaHei',
|
||||
'Noto Sans CJK SC',
|
||||
];
|
||||
|
||||
static const _monoFallback = <String>[
|
||||
'JetBrains Mono',
|
||||
'SF Mono',
|
||||
'Menlo',
|
||||
'Consolas',
|
||||
];
|
||||
|
||||
/// 12 / 16 / w500 / ls 0.02em — `.font-label-caps`
|
||||
static const labelCaps = TextStyle(
|
||||
fontSize: 12,
|
||||
height: 16 / 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.24,
|
||||
fontFamilyFallback: _sansFallback,
|
||||
);
|
||||
|
||||
/// 13 / 18 / w500 — 卡片内的次要信息行
|
||||
static const bodySm = TextStyle(
|
||||
fontSize: 13,
|
||||
height: 18 / 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontFamilyFallback: _sansFallback,
|
||||
);
|
||||
|
||||
/// 15 / 20 / w400 — `.font-body-sm`
|
||||
static const bodyMd = TextStyle(
|
||||
fontSize: 15,
|
||||
height: 20 / 15,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontFamilyFallback: _sansFallback,
|
||||
);
|
||||
|
||||
/// 17 / 22 / w400 / ls -0.01em — `.font-body-lg`
|
||||
static const bodyLg = TextStyle(
|
||||
fontSize: 17,
|
||||
height: 22 / 17,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: -0.17,
|
||||
fontFamilyFallback: _sansFallback,
|
||||
);
|
||||
|
||||
/// 20 / 25 / w600 / ls -0.01em — `.font-headline-md`
|
||||
static const headlineMd = TextStyle(
|
||||
fontSize: 20,
|
||||
height: 25 / 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.2,
|
||||
fontFamilyFallback: _sansFallback,
|
||||
);
|
||||
|
||||
/// 22 / 28 / w700 — 「我的」页用户名
|
||||
static const titleLg = TextStyle(
|
||||
fontSize: 22,
|
||||
height: 28 / 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamilyFallback: _sansFallback,
|
||||
);
|
||||
|
||||
/// 28 / 34 / w700 / ls -0.02em — `.font-nav-large-title-mobile`
|
||||
static const navLargeTitle = TextStyle(
|
||||
fontSize: 28,
|
||||
height: 34 / 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.56,
|
||||
fontFamilyFallback: _sansFallback,
|
||||
);
|
||||
|
||||
/// 32 / 40 / w700 — 登录页品牌字
|
||||
static const displayMd = TextStyle(
|
||||
fontSize: 32,
|
||||
height: 40 / 32,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamilyFallback: _sansFallback,
|
||||
);
|
||||
|
||||
/// 40 / 48 / w700 — 收益页总余额
|
||||
static const displayLg = TextStyle(
|
||||
fontSize: 40,
|
||||
height: 48 / 40,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamilyFallback: _sansFallback,
|
||||
);
|
||||
|
||||
/// 金额 / 编号等需要等宽对齐的场景
|
||||
static const dataMono = TextStyle(
|
||||
fontSize: 14,
|
||||
height: 18 / 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamilyFallback: _monoFallback,
|
||||
);
|
||||
}
|
||||
|
||||
/// 全局默认文字样式,对应 `global.scss` 里 `page` 上的字体设置。
|
||||
TextStyle get appDefaultTextStyle => const TextStyle(
|
||||
fontSize: 15,
|
||||
height: 1.4,
|
||||
color: AppColors.onSurface,
|
||||
fontFamilyFallback: AppTypography._sansFallback,
|
||||
);
|
||||
@@ -0,0 +1,45 @@
|
||||
/// 日期格式化工具。
|
||||
String _pad(int n) => n.toString().padLeft(2, '0');
|
||||
|
||||
/// 统一转 DateTime:兼容 `YYYY-MM-DD`、ISO 字符串与时间戳。
|
||||
DateTime? parseDate(Object? v) {
|
||||
if (v == null) return null;
|
||||
if (v is DateTime) return v;
|
||||
if (v is num) return DateTime.fromMillisecondsSinceEpoch(v.toInt());
|
||||
final s = v.toString();
|
||||
if (s.isEmpty) return null;
|
||||
return DateTime.tryParse(s) ?? DateTime.tryParse(s.replaceAll('-', '/'));
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD`
|
||||
String formatDate(Object? v) {
|
||||
final d = parseDate(v);
|
||||
if (d == null) return '-';
|
||||
return '${d.year}-${_pad(d.month)}-${_pad(d.day)}';
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD HH:mm`
|
||||
String formatDateTime(Object? v) {
|
||||
final d = parseDate(v);
|
||||
if (d == null) return '-';
|
||||
return '${formatDate(d)} ${_pad(d.hour)}:${_pad(d.minute)}';
|
||||
}
|
||||
|
||||
/// 今天的 `YYYY-MM-DD`
|
||||
String todayStr() => formatDate(DateTime.now());
|
||||
|
||||
/// 金额格式化:千分位 + 两位小数,对应源页面 `formatNumber`。
|
||||
String formatMoney(num? value) {
|
||||
final v = (value ?? 0).toDouble();
|
||||
final fixed = v.toStringAsFixed(2);
|
||||
final parts = fixed.split('.');
|
||||
final intPart = parts[0];
|
||||
final negative = intPart.startsWith('-');
|
||||
final digits = negative ? intPart.substring(1) : intPart;
|
||||
final buf = StringBuffer();
|
||||
for (var i = 0; i < digits.length; i++) {
|
||||
if (i > 0 && (digits.length - i) % 3 == 0) buf.write(',');
|
||||
buf.write(digits[i]);
|
||||
}
|
||||
return '${negative ? '-' : ''}$buf.${parts[1]}';
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/// 后端主键为 BIGINT,经 JSON 序列化后是字符串(如 `"1"`)。
|
||||
/// 统一归一化为 int,避免与表单/路由里的 int 型 id 做比较时失效。
|
||||
Object? toNumId(Object? v) {
|
||||
if (v == null || v == '') return v;
|
||||
if (v is int) return v;
|
||||
if (v is num) return v.toInt();
|
||||
final n = int.tryParse(v.toString());
|
||||
return n ?? v;
|
||||
}
|
||||
|
||||
int? toIntOrNull(Object? v) {
|
||||
final n = toNumId(v);
|
||||
return n is int ? n : null;
|
||||
}
|
||||
|
||||
int toIntOr(Object? v, int fallback) => toIntOrNull(v) ?? fallback;
|
||||
|
||||
double toDoubleOr(Object? v, double fallback) {
|
||||
if (v == null) return fallback;
|
||||
if (v is num) return v.toDouble();
|
||||
return double.tryParse(v.toString()) ?? fallback;
|
||||
}
|
||||
Reference in new issue
Block a user