feat: 完成智能电网电工端 Flutter 应用初始化

创建完整的 Flutter 项目结构,包含 Android 和 iOS 平台配置、核心网络层、路由、状态管理及业务模块(订单、收益、认证),适配原 uni-app 项目功能逻辑
This commit is contained in:
andyesfly committed 2026-10-06 11:54:10 +08:00
commit 56830e94ec
108 files changed
+9272

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/app_keys.dart';
import '../core/theme/app_theme.dart';
import '../core/theme/app_typography.dart';
import 'router.dart';
class VoltLinkApp extends ConsumerWidget {
const VoltLinkApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(routerProvider);
return MaterialApp.router(
title: 'VoltLink',
debugShowCheckedModeBanner: false,
theme: AppTheme.build(),
routerConfig: router,
scaffoldMessengerKey: AppKeys.scaffoldMessenger,
locale: const Locale('zh', 'CN'),
supportedLocales: const [Locale('zh', 'CN'), Locale('en', 'US')],
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
// 对应源 `page` 上的全局字体设置。
builder: (context, child) => DefaultTextStyle(
style: appDefaultTextStyle,
child: child ?? const SizedBox.shrink(),
),
);
}
}
+63
View File
@@ -0,0 +1,63 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../core/app_keys.dart';
import '../features/auth/application/session_controller.dart';
import '../features/auth/presentation/login_page.dart';
import '../features/auth/presentation/register_page.dart';
import '../features/notice/presentation/notice_center_page.dart';
import '../features/order/presentation/order_detail_page.dart';
import 'tabs_page.dart';
/// 会话语义变化时驱动 go_router 重新执行 `redirect`。
class _SessionRefresh extends ChangeNotifier {
void bump() => notifyListeners();
}
/// 路由表,对应源 `pages.json` 的 5 个页面。
///
/// 除登录/注册外,其余页面统一使用主题里的 `CupertinoPageTransitionsBuilder`
/// (新页面从右侧滑入),与源 `PageTransition.vue` 的表现一致。
final routerProvider = Provider<GoRouter>((ref) {
final refresh = _SessionRefresh();
ref.listen(sessionProvider, (_, _) => refresh.bump());
ref.onDispose(refresh.dispose);
return GoRouter(
navigatorKey: AppKeys.rootNavigator,
initialLocation: '/login',
refreshListenable: refresh,
redirect: (context, state) {
final loggedIn = ref.read(sessionProvider).isLoggedIn;
final atAuth =
state.matchedLocation == '/login' || state.matchedLocation == '/register';
if (!loggedIn) return atAuth ? null : '/login';
if (atAuth) return '/tabs';
return null;
},
routes: [
GoRoute(path: '/login', builder: (context, state) => const LoginPage()),
GoRoute(
path: '/register',
builder: (context, state) => const RegisterPage(),
),
GoRoute(path: '/tabs', builder: (context, state) => const TabsPage()),
// 消息中心:顶栏铃铛入口
GoRoute(
path: '/notice',
builder: (context, state) => const NoticeCenterPage(),
),
// 订单详情:source 为 repair|install|maintenance|survey
GoRoute(
path: '/order/:source/:id',
builder: (context, state) => OrderDetailPage(
source: state.pathParameters['source']!,
id: int.parse(state.pathParameters['id']!),
),
),
],
);
});
+30
View File
@@ -0,0 +1,30 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 底部导航的三个 Tab,对应 `components/CustomTabBar.vue` 的 3 个入口。
enum AppTab { order, earnings, profile }
extension AppTabKey on AppTab {
/// 与 `XBottomNav.items` 中的 key 一致。
String get key => switch (this) {
AppTab.order => 'order',
AppTab.earnings => 'earnings',
AppTab.profile => 'profile',
};
}
/// 当前 Tab。源里每个页面各自挂 `CustomTabBar` 并用 `redirectTo` 切换,
/// Flutter 端改为单容器 + [IndexedStack],避免页面重建、保留各自滚动位置。
class TabIndexController extends Notifier<AppTab> {
@override
AppTab build() => AppTab.order;
void select(AppTab tab) => state = tab;
void selectByKey(String key) {
state = AppTab.values.firstWhere((t) => t.key == key, orElse: () => state);
}
}
final tabIndexProvider = NotifierProvider<TabIndexController, AppTab>(
TabIndexController.new,
);
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/theme/app_colors.dart';
import '../features/earnings/presentation/earnings_tab.dart';
import '../features/order/presentation/order_tab.dart';
import '../features/profile/presentation/profile_tab.dart';
import '../shared/widgets/x_bottom_nav.dart';
import 'tab_index.dart';
/// 3 个 Tab 的常驻容器。源项目每个页面各自挂 `CustomTabBar`,
/// 这里统一收口到容器层,用 [IndexedStack] 保留各页内部状态。
class TabsPage extends ConsumerWidget {
const TabsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final current = ref.watch(tabIndexProvider);
return Scaffold(
backgroundColor: AppColors.bgPage,
body: Stack(
children: [
Positioned.fill(
child: IndexedStack(
index: current.index,
children: const [OrderTab(), EarningsTab(), ProfileTab()],
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: XBottomNav(
current: current.key,
onChanged: (key) =>
ref.read(tabIndexProvider.notifier).selectByKey(key),
),
),
],
),
);
}
}
+7
View File
@@ -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>();
}
+35
View File
@@ -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);
}
+133
View File
@@ -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;
}
}
+33
View File
@@ -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;
}
+110
View File
@@ -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;
}
}
+59
View File
@@ -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;
}
+116
View File
@@ -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,
};
}
+66
View File
@@ -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)),
];
}
+97
View File
@@ -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)),
);
}
}
+114
View File
@@ -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,
);
+45
View File
@@ -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]}';
}
+22
View File
@@ -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;
}
@@ -0,0 +1,100 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/providers.dart';
import '../../../core/storage/token_storage.dart';
import '../data/auth_api.dart';
import '../data/auth_models.dart';
final authApiProvider = Provider<AuthApi>(
(ref) => AuthApi(ref.watch(apiClientProvider)),
);
class SessionState {
const SessionState({this.worker, required this.isLoggedIn});
final WorkerInfo? worker;
final bool isLoggedIn;
SessionState copyWith({WorkerInfo? worker, bool? isLoggedIn}) =>
SessionState(
worker: worker ?? this.worker,
isLoggedIn: isLoggedIn ?? this.isLoggedIn,
);
}
/// 登录态。对应源 `store/user.ts` 的登录/登出与本地缓存恢复。
class SessionController extends Notifier<SessionState> {
TokenStorage get _storage => ref.read(tokenStorageProvider);
@override
SessionState build() {
// 拦截器判定会话失效(401)时,清空本地会话并回到登录页。
final sub = ref
.watch(apiClientProvider)
.onUnauthorized
.listen((_) => clearLocal());
ref.onDispose(sub.cancel);
final cached = _storage.userInfo;
return SessionState(
worker: cached == null ? null : WorkerInfo.fromJson(cached),
isLoggedIn: _storage.hasSession,
);
}
/// 登录成功后写入 token 与电工信息。
Future<void> login({
required String phone,
required String password,
}) async {
final res = await ref
.read(authApiProvider)
.login(phone: phone, password: password);
if (!res.isOk || res.data == null) {
throw AuthException(res.message.isEmpty ? '登录失败' : res.message);
}
final data = res.data!;
await _storage.setAccessToken(data.token);
await _storage.setUserInfo(data.workerInfo.toJson());
state = SessionState(worker: data.workerInfo, isLoggedIn: true);
}
/// 从后端刷新电工信息(「我的」页进入时调用)。
Future<WorkerInfo?> refreshProfile() async {
try {
final res = await ref.read(authApiProvider).profile();
if (res.isOk && res.data != null) {
final info = res.data!.basic;
await _storage.setUserInfo(info.toJson());
state = SessionState(worker: info, isLoggedIn: true);
return info;
}
} catch (_) {
// 拉取失败保持原状态
}
return null;
}
Future<void> logout() async {
await _storage.clearSession();
state = const SessionState(isLoggedIn: false, worker: null);
}
/// 清空本地会话但不跳转(被拦截器判定会话失效时调用)。
Future<void> clearLocal() => logout();
}
final sessionProvider = NotifierProvider<SessionController, SessionState>(
SessionController.new,
);
/// 会话层业务异常,携带后端返回的可读 message。
class AuthException implements Exception {
const AuthException(this.message);
final String message;
@override
String toString() => message;
}
+58
View File
@@ -0,0 +1,58 @@
import '../../../core/network/api_client.dart';
import '../../../core/network/api_response.dart';
import 'auth_models.dart';
/// 认证与个人资料接口,对应后端 `/api/worker-app/auth/*` 与 `/profile`。
class AuthApi {
AuthApi(this._client);
final ApiClient _client;
Future<ApiResponse<LoginResult>> login({
required String phone,
required String password,
}) => _client.post<LoginResult>(
'/worker-app/auth/login',
body: {'phone': phone, 'password': password},
parse: (d) => LoginResult.fromJson((d as Map).cast<String, dynamic>()),
needAuth: false,
);
Future<ApiResponse<WorkerInfo>> register({
required String workerName,
required String phone,
required String password,
String? idCard,
String? workerTitle,
}) => _client.post<WorkerInfo>(
'/worker-app/auth/register',
body: {
'workerName': workerName,
'phone': phone,
'password': password,
if (idCard != null && idCard.isNotEmpty) 'idCard': idCard,
if (workerTitle != null && workerTitle.isNotEmpty) 'workerTitle': workerTitle,
},
parse: (d) => WorkerInfo.fromJson((d as Map).cast<String, dynamic>()),
needAuth: false,
);
Future<ApiResponse<WorkerProfile>> profile() => _client.get<WorkerProfile>(
'/worker-app/profile',
parse: (d) => WorkerProfile.fromJson((d as Map).cast<String, dynamic>()),
);
Future<ApiResponse<WorkerInfo>> updateProfile({
String? workerName,
String? idCard,
String? workerTitle,
}) => _client.put<WorkerInfo>(
'/worker-app/profile',
body: {
'workerName': ?workerName,
'idCard': ?idCard,
'workerTitle': ?workerTitle,
},
parse: (d) => WorkerInfo.fromJson((d as Map).cast<String, dynamic>()),
);
}
+151
View File
@@ -0,0 +1,151 @@
import '../../../core/utils/id_util.dart';
/// 电工基础信息,对应后端 `WorkerAppService.basicInfo()`。
class WorkerInfo {
const WorkerInfo({
required this.id,
required this.workerName,
required this.phone,
required this.workerTitle,
required this.auditStatus,
required this.status,
required this.rating,
required this.completedCount,
});
final int id;
final String workerName;
final String phone;
final String workerTitle;
/// 审核状态:0 待审核 / 1 已通过 / 2 已驳回
final int auditStatus;
/// 在职状态:0 离职 / 1 在职
final int status;
final double rating;
final int completedCount;
String get displayName => workerName.isNotEmpty ? workerName : phone;
/// 头像占位文字:取显示名首个字符(按 Unicode 码点截取,兼容中文)。
String get avatarText =>
displayName.isEmpty ? '电' : String.fromCharCode(displayName.runes.first);
bool get isApproved => auditStatus == 1;
String get auditStatusText => switch (auditStatus) {
0 => '待审核',
1 => '已通过',
2 => '已驳回',
_ => '未知',
};
factory WorkerInfo.fromJson(Map<String, dynamic> json) => WorkerInfo(
id: toIntOr(json['id'], 0),
workerName: json['workerName'] as String? ?? '',
phone: json['phone'] as String? ?? '',
workerTitle: json['workerTitle'] as String? ?? '',
auditStatus: toIntOr(json['auditStatus'], 0),
status: toIntOr(json['status'], 0),
rating: toDoubleOr(json['rating'], 5),
completedCount: toIntOr(json['completedCount'], 0),
);
Map<String, dynamic> toJson() => {
'id': id,
'workerName': workerName,
'phone': phone,
'workerTitle': workerTitle,
'auditStatus': auditStatus,
'status': status,
'rating': rating,
'completedCount': completedCount,
};
}
/// 登录返回体。
class LoginResult {
const LoginResult({required this.token, required this.workerInfo});
final String token;
final WorkerInfo workerInfo;
factory LoginResult.fromJson(Map<String, dynamic> json) => LoginResult(
token: json['token'] as String? ?? '',
workerInfo: WorkerInfo.fromJson(
(json['workerInfo'] as Map?)?.cast<String, dynamic>() ?? const {},
),
);
}
/// 电工资质证照,对应 `worker_cert` 表。
class CertInfo {
const CertInfo({
required this.id,
required this.certType,
required this.certNo,
required this.issueAuthority,
required this.issueDate,
required this.expireDate,
required this.certImageUrl,
required this.status,
});
final int id;
final String certType;
final String certNo;
final String issueAuthority;
final String issueDate;
final String expireDate;
final String certImageUrl;
/// 有效期状态:0 正常 / 1 即将到期 / 2 已过期
final int status;
factory CertInfo.fromJson(Map<String, dynamic> json) => CertInfo(
id: toIntOr(json['id'], 0),
certType: json['certType'] as String? ?? '',
certNo: json['certNo'] as String? ?? '',
issueAuthority: json['issueAuthority'] as String? ?? '',
issueDate: json['issueDate'] as String? ?? '',
expireDate: json['expireDate'] as String? ?? '',
certImageUrl: json['certImageUrl'] as String? ?? '',
status: toIntOr(json['status'], 0),
);
}
/// 个人资料,对应 `GET /api/worker-app/profile`。
class WorkerProfile {
const WorkerProfile({
required this.basic,
required this.idCard,
required this.auditRemark,
required this.certStatus,
required this.certs,
});
final WorkerInfo basic;
final String idCard;
final String auditRemark;
/// 资质汇总状态:none / valid / expiring / expired
final String certStatus;
final List<CertInfo> certs;
/// 「我的」页证书区取首张证书展示(源页面只展示一张「电工职业资格证」)。
CertInfo? get primaryCert => certs.isEmpty ? null : certs.first;
factory WorkerProfile.fromJson(Map<String, dynamic> json) => WorkerProfile(
basic: WorkerInfo.fromJson(json),
idCard: json['idCard'] as String? ?? '',
auditRemark: json['auditRemark'] as String? ?? '',
certStatus: json['certStatus'] as String? ?? 'none',
certs:
(json['certs'] as List?)
?.map((e) => CertInfo.fromJson((e as Map).cast<String, dynamic>()))
.toList() ??
const [],
);
}
@@ -0,0 +1,717 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/env.dart';
import '../../../core/providers.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_typography.dart';
import '../../../shared/widgets/app_feedback.dart';
import '../../../shared/widgets/x_common.dart';
import '../../../shared/widgets/x_icon.dart';
import '../application/session_controller.dart';
/// 是否在 release 包里也显示「服务端设置」入口。
///
/// 默认关闭,正式包不暴露改地址的口子;打内部测试包时用
/// `--dart-define=ENABLE_SERVER_SETTINGS=true` 打开。
const _serverSettingsEnabled = bool.fromEnvironment('ENABLE_SERVER_SETTINGS');
/// 登录页,对应源 `pages/login/index.vue`。
///
/// **与源的差异(已与用户确认)**:源为「手机号 + 验证码」,后端只支持
/// 「手机号 + 密码」,故此处改为手机号 + 密码,并新增注册入口。
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@override
ConsumerState<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage> {
final _phone = TextEditingController();
final _password = TextEditingController();
bool _agree = false;
bool _obscure = true;
bool _loading = false;
String _error = '';
@override
void initState() {
super.initState();
_phone.addListener(_onChanged);
_password.addListener(_onChanged);
}
@override
void dispose() {
_phone.dispose();
_password.dispose();
super.dispose();
}
void _onChanged() => setState(() {});
bool get _canLogin =>
_phone.text.length == 11 && _password.text.length >= 6 && _agree;
Future<void> _handleLogin() async {
if (!_canLogin) {
if (!_agree) _error = '请先阅读并同意服务协议与隐私政策';
setState(() {});
return;
}
setState(() {
_loading = true;
_error = '';
});
try {
await ref
.read(sessionProvider.notifier)
.login(phone: _phone.text.trim(), password: _password.text);
if (!mounted) return;
context.go('/tabs');
} on AuthException catch (e) {
if (mounted) setState(() => _error = e.message);
} catch (_) {
if (mounted) setState(() => _error = '网络异常,请检查服务端地址');
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFF0F4FF), Color(0xFFFAF9FE)],
),
),
child: Stack(
children: [
Positioned.fill(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 80, 24, 60),
child: Column(
children: [
_buildLogoSection(),
_buildFormSection(),
_buildOtherLogin(),
_buildAgreement(),
const SizedBox(height: 24),
_buildRegisterEntry(),
],
),
),
),
// 测试用「服务端设置」入口:调试版常显,release 需显式开关
if (kDebugMode || _serverSettingsEnabled)
Positioned(
top: 0,
right: 0,
child: SafeArea(
child: GestureDetector(
onTap: _openServerSettings,
behavior: HitTestBehavior.opaque,
child: const Padding(
padding: EdgeInsets.all(12),
child: XIcon(
'settings',
size: 22,
color: AppColors.textMuted,
),
),
),
),
),
],
),
),
);
}
Future<void> _openServerSettings() => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: AppColors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)),
),
builder: (_) => const _ServerSettingsSheet(),
);
// ---- 各区块 ----
Widget _buildLogoSection() {
return Padding(
padding: const EdgeInsets.only(bottom: 48),
child: Column(
children: [
// .logo 96×96 / #0052ff / radius 20 / 0 8px 24px rgba(0,82,255,.3)
Container(
width: 96,
height: 96,
margin: const EdgeInsets.only(bottom: 24),
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(20),
boxShadow: const [
BoxShadow(
color: Color(0x4D0052FF),
blurRadius: 24,
offset: Offset(0, 8),
),
],
),
alignment: Alignment.center,
child: const XIcon('shandian', size: 48, color: Colors.white),
),
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'VoltLink',
style: AppTypography.displayMd.copyWith(
color: AppColors.onSurface,
),
),
),
const Text(
'欢迎回到 VoltLink',
style: TextStyle(fontSize: 16, color: AppColors.textMuted),
),
],
),
);
}
Widget _buildFormSection() {
return Padding(
padding: const EdgeInsets.only(bottom: 48),
child: Column(
children: [
// .input-group:白底 / radius 16 / padding 1px
Container(
decoration: BoxDecoration(
color: AppColors.surfaceContainerLowest,
borderRadius: BorderRadius.circular(AppRadius.xl),
),
child: Column(
children: [
_buildPhoneRow(),
_buildPasswordRow(),
],
),
),
const SizedBox(height: 24),
_buildLoginButton(),
if (_error.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 14),
child: Text(
_error,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 13, color: AppColors.error),
),
),
],
),
);
}
/// 手机号行:+86 国家码 + 分隔线 + 输入框。
Widget _buildPhoneRow() {
return Container(
height: 56,
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: AppColors.surfaceContainerHighest)),
),
child: Row(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
Text(
'+86',
style: AppTypography.bodyLg.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.onSurface,
),
),
const SizedBox(width: 4),
const Text(
'▼',
style: TextStyle(fontSize: 12, color: AppColors.textMuted),
),
],
),
),
Container(width: 1, height: 32, color: AppColors.surfaceContainerHighest),
Expanded(
child: TextField(
controller: _phone,
keyboardType: TextInputType.phone,
maxLength: 11,
style: const TextStyle(fontSize: 17, color: AppColors.onSurface),
cursorColor: AppColors.primary,
decoration: _inputDecoration('请输入手机号码'),
),
),
],
),
);
}
/// 密码行:源为「验证码 + 获取验证码」,后端改为密码,右侧按钮换成明文切换。
Widget _buildPasswordRow() {
return SizedBox(
height: 56,
child: Row(
children: [
Expanded(
child: TextField(
controller: _password,
obscureText: _obscure,
style: const TextStyle(fontSize: 17, color: AppColors.onSurface),
cursorColor: AppColors.primary,
decoration: _inputDecoration('请输入密码(至少 6 位)'),
),
),
GestureDetector(
onTap: () => setState(() => _obscure = !_obscure),
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
child: Text(
_obscure ? '显示' : '隐藏',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: AppColors.primary,
),
),
),
),
],
),
);
}
InputDecoration _inputDecoration(String hint) => InputDecoration(
isDense: true,
border: InputBorder.none,
counterText: '',
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
hintText: hint,
hintStyle: const TextStyle(fontSize: 17, color: AppColors.textPlaceholder),
);
Widget _buildLoginButton() {
final enabled = _canLogin && !_loading;
return GestureDetector(
onTap: enabled ? _handleLogin : null,
behavior: HitTestBehavior.opaque,
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
height: 56,
decoration: BoxDecoration(
color: enabled ? AppColors.primary : AppColors.textPlaceholder,
borderRadius: BorderRadius.circular(AppRadius.xl),
boxShadow: enabled
? const [
BoxShadow(
color: Color(0x4D0052FF),
blurRadius: 20,
offset: Offset(0, 8),
),
]
: null,
),
alignment: Alignment.center,
child: _loading
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(Colors.white),
),
)
: Row(
mainAxisSize: MainAxisSize.min,
children: const [
Text(
'立即登录',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
SizedBox(width: 8),
Text(
'→',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w300,
color: Colors.white,
),
),
],
),
),
);
}
Widget _buildOtherLogin() {
return Padding(
padding: const EdgeInsets.only(bottom: 48),
child: Column(
children: [
const Padding(
padding: EdgeInsets.only(bottom: 24),
child: Row(
children: [
Expanded(
child: SizedBox(
height: 1,
child: ColoredBox(color: AppColors.surfaceContainerHighest),
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Text(
'微信快捷登录',
style: TextStyle(fontSize: 13, color: AppColors.textMuted),
),
),
Expanded(
child: SizedBox(
height: 1,
child: ColoredBox(color: AppColors.surfaceContainerHighest),
),
),
],
),
),
GestureDetector(
onTap: () => showAppToast('微信登录暂未开放'),
behavior: HitTestBehavior.opaque,
child: Container(
width: 56,
height: 56,
decoration: const BoxDecoration(
color: Color(0xFF07C160),
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: const XIcon('weixin', size: 32, color: Colors.white),
),
),
],
),
);
}
Widget _buildAgreement() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () => setState(() => _agree = !_agree),
behavior: HitTestBehavior.opaque,
child: Container(
width: 20,
height: 20,
margin: const EdgeInsets.only(top: 1),
decoration: BoxDecoration(
color: _agree ? AppColors.primary : Colors.transparent,
border: Border.all(
color: _agree ? AppColors.primary : AppColors.textPlaceholder,
width: 2,
),
borderRadius: BorderRadius.circular(6),
),
alignment: Alignment.center,
child: _agree
? const Text(
'✓',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.white,
height: 1,
),
)
: null,
),
),
const SizedBox(width: 12),
Expanded(
child: RichText(
text: TextSpan(
style: const TextStyle(
fontSize: 13,
height: 1.6,
color: AppColors.textMuted,
),
children: [
const TextSpan(text: '我已阅读并同意'),
_link('《VoltLink服务协议》'),
const TextSpan(text: '与'),
_link('《隐私政策》'),
const TextSpan(text: ',并授权 VoltLink 在登录时获取我的相关信息。'),
],
),
),
),
],
),
);
}
TextSpan _link(String text) => TextSpan(
text: text,
style: const TextStyle(color: AppColors.primary),
recognizer: TapGestureRecognizer()
..onTap = () => showAppToast('$text 暂未开放'),
);
Widget _buildRegisterEntry() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'还没有账号?',
style: TextStyle(fontSize: 14, color: AppColors.textMuted),
),
GestureDetector(
onTap: () => context.push('/register'),
behavior: HitTestBehavior.opaque,
child: const Padding(
padding: EdgeInsets.symmetric(vertical: 6, horizontal: 4),
child: Text(
'立即注册',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
),
),
],
);
}
}
/// 调试版「服务端设置」弹窗:只让改 IP 与端口,
/// 协议与路径沿用 [Env.apiBaseUrl] 的编译期配置。
class _ServerSettingsSheet extends ConsumerStatefulWidget {
const _ServerSettingsSheet();
@override
ConsumerState<_ServerSettingsSheet> createState() =>
_ServerSettingsSheetState();
}
class _ServerSettingsSheetState extends ConsumerState<_ServerSettingsSheet> {
late final TextEditingController _host;
late final TextEditingController _port;
String _error = '';
@override
void initState() {
super.initState();
final saved = ref.read(serverAddressProvider);
_host = TextEditingController(
text: saved.host.isEmpty ? Env.defaultHost : saved.host,
);
_port = TextEditingController(
text: saved.port.isEmpty ? '${Env.defaultPort}' : saved.port,
);
}
@override
void dispose() {
_host.dispose();
_port.dispose();
super.dispose();
}
String get _preview {
final host = _host.text.trim();
return Env.buildBaseUrl(
host.isEmpty ? Env.defaultHost : host,
_port.text.trim(),
);
}
Future<void> _save() async {
final host = _host.text.trim();
final port = _port.text.trim();
if (host.isEmpty) {
setState(() => _error = '请输入服务端 IP');
return;
}
if (host.contains('://') || host.contains('/') || host.contains(':')) {
setState(() => _error = '只填 IP 或域名,不要带 http:// 和端口');
return;
}
final parsedPort = int.tryParse(port);
if (parsedPort == null || parsedPort < 1 || parsedPort > 65535) {
setState(() => _error = '端口需为 1 - 65535 的整数');
return;
}
// 保存后 serverAddressProvider 变化会重建 ApiClient,立即生效
await ref.read(serverAddressProvider.notifier).save(host, port);
if (!mounted) return;
Navigator.of(context).pop();
showAppToast('已切换到 ${Env.buildBaseUrl(host, port)}', success: true);
}
Future<void> _reset() async {
await ref.read(serverAddressProvider.notifier).reset();
if (!mounted) return;
Navigator.of(context).pop();
showAppToast('已恢复默认地址', success: true);
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 28),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'服务端设置',
style: AppTypography.headlineMd.copyWith(
color: AppColors.onSurface,
),
),
const SizedBox(height: 6),
const Text(
'保存后立即生效,无需重启 App。',
style: TextStyle(fontSize: 13, color: AppColors.textMuted),
),
const SizedBox(height: 20),
_buildField(
label: '服务端 IP',
controller: _host,
hintText: '如 192.168.3.29',
keyboardType: TextInputType.url,
),
const SizedBox(height: 16),
_buildField(
label: '端口',
controller: _port,
hintText: '如 7001',
keyboardType: TextInputType.number,
),
const SizedBox(height: 14),
// 保存前实时预览拼接结果,避免地址拼错
ListenableBuilder(
listenable: Listenable.merge([_host, _port]),
builder: (context, _) => Text(
'生效地址:$_preview',
style: AppTypography.dataMono.copyWith(
color: AppColors.textMuted,
),
),
),
if (_error.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
_error,
style: const TextStyle(fontSize: 13, color: AppColors.error),
),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: XPrimaryButton(
label: '恢复默认',
onPressed: _reset,
background: AppColors.surfaceContainerHigh,
foreground: AppColors.onSurfaceVariant,
),
),
const SizedBox(width: 12),
Expanded(
child: XPrimaryButton(label: '保存', onPressed: _save),
),
],
),
],
),
),
);
}
Widget _buildField({
required String label,
required TextEditingController controller,
required String hintText,
required TextInputType keyboardType,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: AppTypography.bodySm.copyWith(
color: AppColors.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: AppColors.surfaceBright,
border: Border.all(color: AppColors.borderSoft),
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: TextField(
controller: controller,
keyboardType: keyboardType,
autocorrect: false,
style: const TextStyle(fontSize: 15, color: AppColors.onSurface),
cursorColor: AppColors.primary,
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
hintText: hintText,
hintStyle: const TextStyle(
fontSize: 15,
color: AppColors.textPlaceholder,
),
),
),
),
],
);
}
}
@@ -0,0 +1,252 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/network/api_response.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_typography.dart';
import '../../../shared/widgets/app_feedback.dart';
import '../../../shared/widgets/x_card.dart';
import '../../../shared/widgets/x_common.dart';
import '../../../shared/widgets/x_top_bar.dart';
import '../application/session_controller.dart';
/// 注册页。
///
/// 源工程登录页只有「手机号 + 验证码」,没有注册页;后端 `/auth/register`
/// 支持手机号 + 密码开户,因此这里补一个与登录页同一套视觉语言的注册表单。
class RegisterPage extends ConsumerStatefulWidget {
const RegisterPage({super.key});
@override
ConsumerState<RegisterPage> createState() => _RegisterPageState();
}
class _RegisterPageState extends ConsumerState<RegisterPage> {
final _name = TextEditingController();
final _phone = TextEditingController();
final _password = TextEditingController();
final _idCard = TextEditingController();
final _title = TextEditingController();
bool _loading = false;
String _error = '';
@override
void initState() {
super.initState();
for (final c in [_name, _phone, _password]) {
c.addListener(_onChanged);
}
}
@override
void dispose() {
_name.dispose();
_phone.dispose();
_password.dispose();
_idCard.dispose();
_title.dispose();
super.dispose();
}
void _onChanged() => setState(() {});
bool get _canSubmit =>
_name.text.trim().isNotEmpty &&
_phone.text.length == 11 &&
_password.text.length >= 6;
Future<void> _handleRegister() async {
if (!_canSubmit) {
setState(() => _error = '请填写姓名、11 位手机号与至少 6 位密码');
return;
}
setState(() {
_loading = true;
_error = '';
});
try {
final res = await ref
.read(authApiProvider)
.register(
workerName: _name.text.trim(),
phone: _phone.text.trim(),
password: _password.text,
idCard: _idCard.text.trim(),
workerTitle: _title.text.trim(),
);
if (!mounted) return;
if (!res.isOk) {
setState(() => _error = res.message.isEmpty ? '注册失败' : res.message);
return;
}
showAppToast('注册成功,请登录', success: true);
context.pop();
} on ApiException catch (e) {
if (mounted) setState(() => _error = e.message);
} catch (_) {
if (mounted) setState(() => _error = '网络异常,请稍后重试');
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: XPageShell(
topBar: XTopBar.nav(title: '注册账号', onBack: () => context.pop()),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 40),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
XCard(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Field(
label: '姓名',
hint: '请输入真实姓名',
controller: _name,
required: true,
),
const SizedBox(height: 18),
_Field(
label: '手机号',
hint: '请输入 11 位手机号',
controller: _phone,
required: true,
keyboardType: TextInputType.phone,
maxLength: 11,
),
const SizedBox(height: 18),
_Field(
label: '密码',
hint: '至少 6 位',
controller: _password,
required: true,
obscure: true,
),
const SizedBox(height: 18),
_Field(
label: '身份证号',
hint: '选填',
controller: _idCard,
),
const SizedBox(height: 18),
_Field(
label: '工种 / 职称',
hint: '选填,如 高压电工',
controller: _title,
),
],
),
),
const SizedBox(height: 20),
XPrimaryButton(
label: '提交注册',
height: 56,
radius: AppRadius.xl,
loading: _loading,
onPressed: _canSubmit ? _handleRegister : null,
shadows: AppShadows.primaryButton,
),
if (_error.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 14),
child: Text(
_error,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 13, color: AppColors.error),
),
),
const SizedBox(height: 16),
const Text(
'注册后需等待平台审核通过,方可接单。',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: AppColors.textMuted),
),
],
),
),
),
);
}
}
class _Field extends StatelessWidget {
const _Field({
required this.label,
required this.hint,
required this.controller,
this.required = false,
this.obscure = false,
this.keyboardType,
this.maxLength,
});
final String label;
final String hint;
final TextEditingController controller;
final bool required;
final bool obscure;
final TextInputType? keyboardType;
final int? maxLength;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
label,
style: AppTypography.bodySm.copyWith(
color: AppColors.onSurfaceVariant,
),
),
if (required)
const Text(
' *',
style: TextStyle(fontSize: 13, color: AppColors.error),
),
],
),
const SizedBox(height: 8),
Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: AppColors.surfaceBright,
border: Border.all(color: AppColors.borderSoft),
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: TextField(
controller: controller,
obscureText: obscure,
keyboardType: keyboardType,
maxLength: maxLength,
style: const TextStyle(fontSize: 15, color: AppColors.onSurface),
cursorColor: AppColors.primary,
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
counterText: '',
hintText: hint,
hintStyle: const TextStyle(
fontSize: 15,
color: AppColors.textPlaceholder,
),
),
),
),
],
);
}
}
@@ -0,0 +1,18 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/api_response.dart';
import '../../../core/providers.dart';
import '../data/earnings_api.dart';
import '../data/earnings_models.dart';
final earningsApiProvider = Provider<EarningsApi>(
(ref) => EarningsApi(ref.watch(apiClientProvider)),
);
final earningsSummaryProvider =
FutureProvider.autoDispose<EarningsSummary>((ref) async {
final res = await ref.watch(earningsApiProvider).summary();
if (!res.isOk) throw ApiException(res.message);
if (res.data == null) throw const ApiException('收益数据为空');
return res.data!;
});
@@ -0,0 +1,16 @@
import '../../../core/network/api_client.dart';
import '../../../core/network/api_response.dart';
import 'earnings_models.dart';
/// 收益接口,对应后端 `/api/worker-app/earnings/summary`。
class EarningsApi {
EarningsApi(this._client);
final ApiClient _client;
Future<ApiResponse<EarningsSummary>> summary() =>
_client.get<EarningsSummary>(
'/worker-app/earnings/summary',
parse: (d) => EarningsSummary.fromJson((d as Map).cast<String, dynamic>()),
);
}
@@ -0,0 +1,94 @@
import '../../../core/utils/id_util.dart';
/// 收益流水,对应后端 `earningsSummary().data.transactions[]`。
///
/// 说明:后端没有提现记录来源,因此实际只会返回 `type == 'earning'` 的入账流水。
class EarningTransaction {
const EarningTransaction({
required this.id,
required this.type,
required this.title,
required this.subtitle,
required this.amount,
required this.status,
required this.icon,
});
final String id;
/// earning | withdrawal
final String type;
final String title;
final String subtitle;
final double amount;
/// settled | pending | completed
final String status;
final String icon;
bool get isWithdrawal => type == 'withdrawal';
String get statusText => switch (status) {
'settled' => '已结算',
'pending' => '处理中',
'completed' => '已完成',
_ => status,
};
factory EarningTransaction.fromJson(Map<String, dynamic> json) =>
EarningTransaction(
id: json['id'] as String? ?? '',
type: json['type'] as String? ?? 'earning',
title: json['title'] as String? ?? '',
subtitle: json['subtitle'] as String? ?? '',
amount: toDoubleOr(json['amount'], 0),
status: json['status'] as String? ?? 'settled',
icon: json['icon'] as String? ?? '',
);
}
/// 收益汇总,对应 `GET /api/worker-app/earnings/summary`。
class EarningsSummary {
const EarningsSummary({
required this.totalBalance,
required this.withdrawableAmount,
required this.monthlyEarnings,
required this.weeklyTrend,
required this.transactions,
});
final double totalBalance;
final double withdrawableAmount;
final double monthlyEarnings;
/// 近 7 天每日收益(index 6 = 今天)。
final List<double> weeklyTrend;
final List<EarningTransaction> transactions;
/// 柱状图高度百分比:以 7 日峰值为 100%,全 0 时给一个最小可见高度。
List<double> get trendPercents {
final max = weeklyTrend.isEmpty
? 0.0
: weeklyTrend.reduce((a, b) => a > b ? a : b);
if (max <= 0) return List.filled(weeklyTrend.length, 8);
return weeklyTrend.map((v) => (v / max * 100).clamp(8, 100).toDouble()).toList();
}
factory EarningsSummary.fromJson(Map<String, dynamic> json) => EarningsSummary(
totalBalance: toDoubleOr(json['totalBalance'], 0),
withdrawableAmount: toDoubleOr(json['withdrawableAmount'], 0),
monthlyEarnings: toDoubleOr(json['monthlyEarnings'], 0),
weeklyTrend:
(json['weeklyTrend'] as List?)?.map((e) => toDoubleOr(e, 0)).toList() ??
const [0, 0, 0, 0, 0, 0, 0],
transactions:
(json['transactions'] as List?)
?.map(
(e) => EarningTransaction.fromJson(
(e as Map).cast<String, dynamic>(),
),
)
.toList() ??
const [],
);
}
@@ -0,0 +1,482 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/providers.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_typography.dart';
import '../../../shared/widgets/app_feedback.dart';
import '../../../shared/widgets/x_card.dart';
import '../../../shared/widgets/x_common.dart';
import '../../../shared/widgets/x_icon.dart';
import '../../../shared/widgets/x_top_bar.dart';
import '../application/earnings_providers.dart';
import '../data/earnings_models.dart';
/// rgba(255,255,255,0.8) —— 余额卡内的次级文字。
const _white80 = Color(0xCCFFFFFF);
/// 柱状图绘图区高度(趋势卡总高 160,余下留给 8px 间距与周标签)。
const _barPlotHeight = 130.0;
/// 「收益」Tab,逐像素复刻源 `src/pages/earnings/index.vue`。
///
/// 顶栏由本页渲染;底部 Tab 栏由 `TabsPage` 统一提供,故此处不渲染导航。
class EarningsTab extends ConsumerWidget {
const EarningsTab({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final summary = ref.watch(earningsSummaryProvider);
final unread = ref.watch(noticeUnreadCountProvider);
return XPageShell(
topBar: XTopBar.brand(
height: 56,
logoSize: 28,
titleSize: 18,
titleWeight: FontWeight.w600,
trailingBadge: unread,
onTrailingTap: () => context.push('/notice'),
),
child: RefreshIndicator(
color: AppColors.primary,
onRefresh: () async {
ref.invalidate(earningsSummaryProvider);
await ref.read(earningsSummaryProvider.future);
},
child: summary.when(
loading: () => _placeholder('加载中...'),
error: (error, _) => _placeholder(error.toString()),
data: (data) => ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 96),
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 24, 16, 24),
child: XPageTitle(
title: '收益中心',
subtitle: '管理您的职业收入与提现详情',
),
),
_balanceCard(data),
_trendSection(data),
_transactionsSection(data),
_referralCard(),
],
),
),
),
);
}
/// 加载 / 错误占位,保持可下拉(`AlwaysScrollableScrollPhysics`)。
Widget _placeholder(String text) => ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [XPlaceholder(text: text)],
);
/// 余额卡:渐变底 + 总余额 + 两列指标 + 白色提现按钮。
Widget _balanceCard(EarningsSummary data) {
return Container(
margin: const EdgeInsets.fromLTRB(16, 0, 16, 24),
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.primaryDeep, AppColors.primary],
),
borderRadius: BorderRadius.circular(AppRadius.xl),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'总余额',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: _white80,
letterSpacing: 0.24,
),
),
const SizedBox(height: 8),
Text(
'¥ ${_formatAmount(data.totalBalance)}',
style: AppTypography.displayLg.copyWith(
color: AppColors.onPrimary,
letterSpacing: -0.8,
),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: _balanceItem(
'可提现金额',
_formatAmount(data.withdrawableAmount),
),
),
const SizedBox(width: 16),
Expanded(
child: _balanceItem(
'本月累计收益',
_formatAmount(data.monthlyEarnings),
),
),
],
),
const SizedBox(height: 24),
SizedBox(
height: 44,
width: double.infinity,
child: Material(
color: AppColors.surfaceContainerLowest,
borderRadius: BorderRadius.circular(10),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: () => showAppToast('提现功能'),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
XIcon('earnings', size: 20, color: AppColors.primary),
SizedBox(width: 8),
Text(
'立即提现',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
],
),
),
),
),
],
),
);
}
Widget _balanceItem(String label, String value) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(fontSize: 12, color: _white80)),
const SizedBox(height: 4),
Text(
'¥ $value',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: AppColors.onPrimary,
),
),
],
);
/// 最近 7 天趋势:柱高取 `trendPercents`,高亮今天(末位)。
Widget _trendSection(EarningsSummary data) {
final percents = data.trendPercents;
final labels = _weekdayLabels();
final activeIndex = percents.isEmpty ? -1 : percents.length - 1;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'最近7天趋势',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: AppColors.onSurface,
),
),
GestureDetector(
onTap: () => showAppToast('近一周'),
behavior: HitTestBehavior.opaque,
child: const Row(
children: [
Text(
'近一周',
style: TextStyle(
fontSize: 14,
color: AppColors.primary,
fontWeight: FontWeight.w500,
),
),
SizedBox(width: 4),
XIcon('arrow-down', size: 14, color: AppColors.primary),
],
),
),
],
),
const SizedBox(height: 12),
XCard(
radius: AppRadius.lg,
padding: const EdgeInsets.all(24),
child: SizedBox(
height: 160,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (var i = 0; i < percents.length; i++)
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FractionallySizedBox(
widthFactor: 0.8,
child: Container(
height: percents[i] / 100 * _barPlotHeight,
decoration: BoxDecoration(
color: i == activeIndex
? AppColors.primary
: AppColors.primaryFixedDim,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(4),
),
),
),
),
const SizedBox(height: 8),
Text(
labels[i % labels.length],
style: TextStyle(
fontSize: 10,
fontWeight: i == activeIndex
? FontWeight.w600
: FontWeight.w400,
color: i == activeIndex
? AppColors.primary
: AppColors.textMuted,
),
),
],
),
),
],
),
),
),
],
),
);
}
/// 收益明细:入账 / 提现两种配色 + 金额与状态。
Widget _transactionsSection(EarningsSummary data) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'收益明细',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: AppColors.onSurface,
),
),
GestureDetector(
onTap: () => showAppToast('查看全部'),
behavior: HitTestBehavior.opaque,
child: const Text(
'查看全部',
style: TextStyle(
fontSize: 15,
color: AppColors.primary,
fontWeight: FontWeight.w500,
),
),
),
],
),
const SizedBox(height: 12),
for (var i = 0; i < data.transactions.length; i++) ...[
if (i > 0) const SizedBox(height: 8),
_transactionItem(data.transactions[i]),
],
],
),
);
}
Widget _transactionItem(EarningTransaction tx) {
final withdrawal = tx.isWithdrawal;
final settled = tx.status == 'settled' || tx.status == 'completed';
final statusColor = settled ? AppColors.primary : AppColors.textMuted;
return XCard(
radius: AppRadius.lg,
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 40,
height: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
color: withdrawal
? AppColors.surfaceVariant
: AppColors.secondaryFixed,
shape: BoxShape.circle,
),
child: XIcon(
'lightning',
size: 20,
color: withdrawal
? AppColors.onSurfaceVariant
: AppColors.onSecondaryFixed,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tx.title,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.onSurface,
),
),
const SizedBox(height: 2),
Text(
tx.subtitle,
style: const TextStyle(
fontSize: 13,
color: AppColors.textMuted,
),
),
],
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${withdrawal ? '-' : '+'}¥${_formatAmount(tx.amount.abs())}',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: withdrawal
? AppColors.onSurface
: AppColors.tertiaryContainer,
),
),
const SizedBox(height: 4),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: statusColor,
shape: BoxShape.circle,
),
),
const SizedBox(width: 4),
Text(
tx.statusText,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: statusColor,
),
),
],
),
],
),
],
),
);
}
/// 邀请好友卡(源里无实际跳转,点击仅提示)。
Widget _referralCard() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: GestureDetector(
onTap: () => showAppToast('邀请好友加入'),
behavior: HitTestBehavior.opaque,
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.secondaryFixed,
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: AppColors.borderSoft),
boxShadow: AppShadows.card,
),
child: const Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'邀请好友加入',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: AppColors.onSecondaryFixed,
),
),
SizedBox(height: 4),
Text(
'每成功邀请一位专业电工,您将获得 ¥100 额外奖励。',
style: TextStyle(
fontSize: 15,
color: Color(0xB30C006A),
),
),
],
),
),
SizedBox(width: 16),
XIcon('star', size: 48, color: AppColors.primary),
],
),
),
),
);
}
}
/// `formatNumber`:千分位 + 两位小数,空值 / 0 回退 `0.00`(与源实现一致)。
String _formatAmount(double? value) {
if (value == null || value == 0) return '0.00';
final parts = value.toStringAsFixed(2).split('.');
final grouped = parts[0].replaceAllMapped(
RegExp(r'\B(?=(\d{3})+(?!\d))'),
(_) => ',',
);
return '$grouped.${parts[1]}';
}
/// 周标签:index 6 为今天,向前回溯 6 天,保证标签与真实星期一致。
List<String> _weekdayLabels() {
const names = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
final today = DateTime.now();
return [
for (var i = 6; i >= 0; i--)
names[today.subtract(Duration(days: i)).weekday - 1],
];
}
+149
View File
@@ -0,0 +1,149 @@
import '../../../core/network/api_client.dart';
import '../../../core/network/api_response.dart';
import '../../../core/utils/id_util.dart';
/// 站内消息。对应后端 `sys_notice` 的电工端视图。
///
/// 后端 `toJSON()` 返回原始列名,时间字段为下划线命名 `created_at`,
/// `is_read` 为 0/1。
class Notice {
const Notice({
required this.id,
required this.noticeType,
required this.title,
required this.content,
required this.bizType,
required this.bizId,
required this.receiverType,
required this.ruleId,
required this.batchId,
required this.isRead,
required this.createdAt,
});
final int id;
/// 通知类型,如 `cert_expire`。
final String noticeType;
final String title;
final String content;
/// 业务类型,如 `worker_cert`。
final String bizType;
/// 业务主键。
final int? bizId;
/// `user` 管理端 / `worker` 电工端。
final String receiverType;
/// 来源通知规则 / 群发批次,可能为空。
final int? ruleId;
final int? batchId;
final bool isRead;
/// 原始 `created_at`,交给 `format_util` 渲染。
final Object? createdAt;
factory Notice.fromJson(Map<String, dynamic> json) => Notice(
id: toIntOr(json['id'], 0),
noticeType: json['noticeType'] as String? ?? '',
title: json['title'] as String? ?? '',
content: json['content'] as String? ?? '',
bizType: json['bizType'] as String? ?? '',
bizId: toIntOrNull(json['bizId']),
receiverType: json['receiverType'] as String? ?? '',
ruleId: toIntOrNull(json['ruleId']),
batchId: toIntOrNull(json['batchId']),
isRead: _toBool(json['isRead']),
createdAt: json['created_at'],
);
/// 复制为已读,用于标记成功后本地更新,避免整页重拉。
Notice copiedWithRead() => Notice(
id: id,
noticeType: noticeType,
title: title,
content: content,
bizType: bizType,
bizId: bizId,
receiverType: receiverType,
ruleId: ruleId,
batchId: batchId,
isRead: true,
createdAt: createdAt,
);
}
/// 消息分页结果。
class NoticePage {
const NoticePage({
required this.list,
required this.total,
required this.page,
required this.pageSize,
});
final List<Notice> list;
final int total;
final int page;
final int pageSize;
factory NoticePage.fromJson(Map<String, dynamic> json) => NoticePage(
list: (json['list'] as List? ?? const [])
.map((e) => Notice.fromJson((e as Map).cast<String, dynamic>()))
.toList(),
total: toIntOr(json['total'], 0),
page: toIntOr(json['page'], 1),
pageSize: toIntOr(json['pageSize'], 10),
);
}
/// 站内消息中心接口,对应后端 `/api/worker-app/notices*`。
class NoticeApi {
NoticeApi(this._client);
final ApiClient _client;
/// 消息列表。`isRead` 传 true/false 时按已读状态筛选,null 取全部。
///
/// 后端 `Number(query.isRead)` 解析,故这里显式传 1 / 0。
Future<ApiResponse<NoticePage>> list({
int page = 1,
int pageSize = 10,
bool? isRead,
}) => _client.get<NoticePage>(
'/worker-app/notices',
query: {
'page': page,
'pageSize': pageSize,
if (isRead != null) 'isRead': isRead ? 1 : 0,
},
parse: (d) => NoticePage.fromJson((d as Map).cast<String, dynamic>()),
);
/// 未读消息数。
Future<ApiResponse<int>> unreadCount() => _client.get<int>(
'/worker-app/notices/unread-count',
parse: (d) => toIntOr((d as Map)['count'], 0),
);
/// 全部标记已读,返回更新条数。
Future<ApiResponse<int>> readAll() => _client.put<int>(
'/worker-app/notices/read-all',
parse: (d) => toIntOr((d as Map)['updated'], 0),
);
/// 单条标记已读。
Future<ApiResponse<void>> markRead(int id) =>
_client.put<void>('/worker-app/notices/$id/read');
}
bool _toBool(Object? v) {
if (v is bool) return v;
if (v is num) return v != 0;
final s = v?.toString().toLowerCase();
return s == '1' || s == 'true';
}
@@ -0,0 +1,358 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/network/api_response.dart';
import '../../../core/providers.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/utils/format_util.dart';
import '../../../shared/widgets/app_feedback.dart';
import '../../../shared/widgets/x_card.dart';
import '../../../shared/widgets/x_common.dart';
import '../../../shared/widgets/x_top_bar.dart';
import '../data/notice_api.dart';
/// 消息中心:站内通知列表 + 已读标记 + 分页加载。
///
/// 列表在页面内维护(分页游标语义强于 Riverpod 缓存),未读数经
/// [noticeUnreadCountProvider] 统一刷新。
class NoticeCenterPage extends ConsumerStatefulWidget {
const NoticeCenterPage({super.key});
@override
ConsumerState<NoticeCenterPage> createState() => _NoticeCenterPageState();
}
class _NoticeCenterPageState extends ConsumerState<NoticeCenterPage> {
static const _pageSize = 10;
final _scroll = ScrollController();
final _items = <Notice>[];
int _page = 0;
bool _loading = false;
bool _loadingMore = false;
bool _hasMore = true;
String? _error;
bool get _hasUnread => _items.any((n) => !n.isRead);
@override
void initState() {
super.initState();
_scroll.addListener(_onScroll);
_load(reset: true);
}
@override
void dispose() {
_scroll
..removeListener(_onScroll)
..dispose();
super.dispose();
}
void _onScroll() {
if (!_scroll.hasClients) return;
final position = _scroll.position;
if (position.pixels >= position.maxScrollExtent - 200) _loadMore();
}
/// 加载一页。`reset` 为真时回到第 1 页(下拉刷新 / 重试)。
Future<void> _load({bool reset = false}) async {
if (reset) {
if (_loading) return;
setState(() {
_loading = true;
_error = null;
});
} else {
if (_loading || _loadingMore || !_hasMore) return;
setState(() => _loadingMore = true);
}
final nextPage = reset ? 1 : _page + 1;
try {
final res = await ref
.read(noticeApiProvider)
.list(page: nextPage, pageSize: _pageSize);
if (!mounted) return;
if (!res.isOk || res.data == null) {
setState(() {
_loading = false;
_loadingMore = false;
if (reset) _error = res.message.isEmpty ? '消息加载失败' : res.message;
});
return;
}
final data = res.data!;
setState(() {
if (reset) _items.clear();
_items.addAll(data.list);
_page = data.page;
_hasMore = data.list.isNotEmpty && _items.length < data.total;
_loading = false;
_loadingMore = false;
_error = null;
});
} on ApiException catch (e) {
if (!mounted) return;
setState(() {
_loading = false;
_loadingMore = false;
if (reset) _error = e.message;
});
}
}
Future<void> _loadMore() => _load();
Future<void> _markRead(Notice notice) async {
if (notice.isRead) return;
try {
final res = await ref.read(noticeApiProvider).markRead(notice.id);
if (!mounted) return;
if (!res.isOk) {
showAppToast(res.message.isEmpty ? '操作失败' : res.message);
return;
}
final index = _items.indexWhere((n) => n.id == notice.id);
if (index >= 0) {
setState(() => _items[index] = _items[index].copiedWithRead());
}
ref.read(noticeUnreadCountProvider.notifier).refresh();
} on ApiException catch (e) {
if (mounted) showAppToast(e.message);
}
}
Future<void> _markAllRead() async {
if (!_hasUnread) return;
try {
final res = await ref.read(noticeApiProvider).readAll();
if (!mounted) return;
if (!res.isOk) {
showAppToast(res.message.isEmpty ? '操作失败' : res.message);
return;
}
setState(() {
for (var i = 0; i < _items.length; i++) {
if (!_items[i].isRead) _items[i] = _items[i].copiedWithRead();
}
});
showAppToast('已全部标记为已读', success: true);
ref.read(noticeUnreadCountProvider.notifier).refresh();
} on ApiException catch (e) {
if (mounted) showAppToast(e.message);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.surfaceContainerLow,
body: XPageShell(
backgroundColor: AppColors.surfaceContainerLow,
topBar: XTopBar.nav(
title: '消息中心',
onBack: () => context.pop(),
trailing: _hasUnread
? GestureDetector(
onTap: _markAllRead,
behavior: HitTestBehavior.opaque,
child: const Padding(
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 4),
child: Text(
'全部已读',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: AppColors.primary,
),
),
),
)
: const SizedBox.shrink(),
),
child: _buildBody(),
),
);
}
Widget _buildBody() {
if (_loading && _items.isEmpty) {
return const Center(
child: SizedBox(
width: 26,
height: 26,
child: CircularProgressIndicator(
strokeWidth: 2.4,
valueColor: AlwaysStoppedAnimation(AppColors.primary),
),
),
);
}
if (_items.isEmpty) {
return RefreshIndicator(
color: AppColors.primary,
onRefresh: () => _load(reset: true),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
if (_error != null)
XPlaceholder(text: '消息加载失败\n$_error')
else
const XPlaceholder(text: '暂无消息'),
if (_error != null)
Center(
child: TextButton(
onPressed: () => _load(reset: true),
child: const Text('点击重试'),
),
),
],
),
);
}
return RefreshIndicator(
color: AppColors.primary,
onRefresh: () => _load(reset: true),
child: ListView.separated(
controller: _scroll,
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
itemCount: _items.length + 1,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, index) {
if (index == _items.length) return _buildFooter();
final notice = _items[index];
return _NoticeTile(
notice: notice,
onTap: notice.isRead ? null : () => _markRead(notice),
);
},
),
);
}
Widget _buildFooter() {
if (_loadingMore) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(AppColors.primary),
),
),
),
);
}
if (_hasMore) {
return GestureDetector(
onTap: () => _loadMore(),
behavior: HitTestBehavior.opaque,
child: const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: Text(
'加载更多',
style: TextStyle(fontSize: 14, color: AppColors.primary),
),
),
),
);
}
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: Text(
'没有更多了',
style: TextStyle(fontSize: 13, color: AppColors.textMuted),
),
),
);
}
}
/// 单条消息卡片:未读有蓝底高亮 + 红点,点击标记已读。
class _NoticeTile extends StatelessWidget {
const _NoticeTile({required this.notice, this.onTap});
final Notice notice;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final unread = !notice.isRead;
return XCard(
padding: const EdgeInsets.all(16),
background: unread
? AppColors.accentSubtle
: AppColors.surfaceContainerLowest,
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (unread)
Padding(
padding: const EdgeInsets.only(top: 7, right: 8),
child: Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: AppColors.error,
shape: BoxShape.circle,
),
),
),
Expanded(
child: Text(
notice.title.isEmpty ? '系统通知' : notice.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.bodyLg.copyWith(
fontWeight: unread ? FontWeight.w600 : FontWeight.w500,
color: AppColors.onSurface,
),
),
),
const SizedBox(width: 12),
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
formatDateTime(notice.createdAt),
style: AppTypography.labelCaps.copyWith(
color: AppColors.textMuted,
),
),
),
],
),
if (notice.content.isNotEmpty) ...[
const SizedBox(height: 8),
Padding(
padding: EdgeInsets.only(left: unread ? 16 : 0),
child: Text(
notice.content,
style: AppTypography.bodyMd.copyWith(
color: AppColors.onSurfaceVariant,
height: 1.5,
),
),
),
],
],
),
);
}
}
@@ -0,0 +1,34 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/api_response.dart';
import '../../../core/providers.dart';
import '../data/order_api.dart';
import '../data/order_models.dart';
final orderApiProvider = Provider<OrderApi>(
(ref) => OrderApi(ref.watch(apiClientProvider)),
);
/// 订单池(待接订单)。接单成功后由调用方 `invalidate` 触发刷新。
final orderPoolProvider = FutureProvider.autoDispose<List<Order>>((ref) async {
final res = await ref.watch(orderApiProvider).pool();
if (!res.isOk) throw ApiException(res.message);
return res.data ?? const [];
});
/// 我的订单,`status` 为 `pending` / `completed` / null(全部)。
final myOrdersProvider = FutureProvider.autoDispose
.family<List<Order>, String?>((ref, status) async {
final res = await ref.watch(orderApiProvider).myOrders(status: status);
if (!res.isOk) throw ApiException(res.message);
return res.data ?? const [];
});
/// 订单详情。参数为 `(source, id)`。
final orderDetailProvider = FutureProvider.autoDispose
.family<Order, ({String source, int id})>((ref, arg) async {
final res = await ref.watch(orderApiProvider).detail(arg.source, arg.id);
if (!res.isOk) throw ApiException(res.message);
if (res.data == null) throw const ApiException('订单不存在');
return res.data!;
});
+46
View File
@@ -0,0 +1,46 @@
import '../../../core/network/api_client.dart';
import '../../../core/network/api_response.dart';
import 'order_models.dart';
/// 订单接口,对应后端 `/api/worker-app/orders*`。
class OrderApi {
OrderApi(this._client);
final ApiClient _client;
/// 订单池:四类未被接单的订单合并。
Future<ApiResponse<List<Order>>> pool() => _client.get<List<Order>>(
'/worker-app/orders/pool',
parse: _parseList,
);
/// 我的订单。`status` 传 `pending` / `completed`,留空取全部。
Future<ApiResponse<List<Order>>> myOrders({String? status}) =>
_client.get<List<Order>>(
'/worker-app/orders',
query: {'status': status},
parse: _parseList,
);
/// 订单详情。
Future<ApiResponse<Order>> detail(String source, int id) =>
_client.get<Order>(
'/worker-app/orders/$source/$id',
parse: (d) => Order.fromJson((d as Map).cast<String, dynamic>()),
);
/// 接单。订单被他人接走时后端返回 409。
Future<ApiResponse<void>> grab(String source, int id) =>
_client.post<void>('/worker-app/orders/$source/$id/grab');
/// 完成订单。非本人订单后端返回 403。
Future<ApiResponse<void>> complete(String source, int id) =>
_client.put<void>('/worker-app/orders/$source/$id/complete');
static List<Order> _parseList(dynamic data) {
final map = (data as Map).cast<String, dynamic>();
return (map['list'] as List? ?? const [])
.map((e) => Order.fromJson((e as Map).cast<String, dynamic>()))
.toList();
}
}
+113
View File
@@ -0,0 +1,113 @@
import '../../../core/utils/id_util.dart';
/// 统一订单视图,对应后端 `WorkerAppService.toOrderView()`。
///
/// `id` 形如 `repair-12` / `install-5`,`source` 为四类来源之一。
class Order {
const Order({
required this.id,
required this.source,
required this.status,
required this.jobId,
required this.title,
required this.type,
required this.amount,
required this.distance,
required this.schedule,
required this.description,
required this.customerName,
required this.customerPhone,
required this.photos,
required this.address,
required this.laborFee,
required this.materialFee,
required this.estimatedDuration,
});
/// `repair-12`
final String id;
/// repair | install | maintenance | survey
final String source;
final int status;
/// 业务单号(repairNo / installNo / maintenanceNo / surveyNo)
final String jobId;
final String title;
/// urgent | standard | regular
final String type;
final double amount;
final double distance;
final String schedule;
final String description;
final String customerName;
final String customerPhone;
final List<String> photos;
final String address;
final double laborFee;
final double materialFee;
final String estimatedDuration;
/// 来源中文名,用于收益明细副标题等场景。
String get sourceName => switch (source) {
'repair' => '家庭电路维修',
'install' => '设备安装',
'maintenance' => '设备维保',
'survey' => '现场勘察',
_ => '服务订单',
};
/// 列表页类型标签:紧急呼叫 / 标准预约 / 常规巡检。
String get listTypeText => switch (type) {
'urgent' => '紧急呼叫',
'standard' => '标准预约',
_ => '常规巡检',
};
/// 详情页类型标签:紧急 / 标准 / 常规。
String get detailTypeText => switch (type) {
'urgent' => '紧急',
'standard' => '标准',
_ => '常规',
};
/// 客户头像占位文字:后端无头像字段,用姓名首字(与「我的」页一致)。
String get customerAvatarText => customerName.isEmpty
? '客'
: String.fromCharCode(customerName.runes.first);
/// 卡片上展示的订单号:后端 `id` 前缀即来源,取 `jobId` 更贴近源 `#EL-8821` 的观感。
String get displayNo => jobId.isNotEmpty ? jobId : id;
/// 后端接口里的数字主键:`repair-12` → 12。
int get intId {
final idx = id.lastIndexOf('-');
return toIntOr(idx >= 0 ? id.substring(idx + 1) : id, 0);
}
factory Order.fromJson(Map<String, dynamic> json) => Order(
id: json['id'] as String? ?? '',
source: json['source'] as String? ?? '',
status: toIntOr(json['status'], 0),
jobId: json['jobId'] as String? ?? '',
title: json['title'] as String? ?? '',
type: json['type'] as String? ?? 'regular',
amount: toDoubleOr(json['amount'], 0),
distance: toDoubleOr(json['distance'], 0),
schedule: json['schedule'] as String? ?? '',
description: json['description'] as String? ?? '',
customerName: json['customerName'] as String? ?? '',
customerPhone: json['customerPhone'] as String? ?? '',
photos:
(json['photos'] as List?)?.map((e) => e.toString()).toList() ??
const [],
address: json['address'] as String? ?? '',
laborFee: toDoubleOr(json['laborFee'], 0),
materialFee: toDoubleOr(json['materialFee'], 0),
estimatedDuration: json['estimatedDuration'] as String? ?? '',
);
}
@@ -0,0 +1,739 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/utils/format_util.dart';
import '../../../shared/widgets/app_feedback.dart';
import '../../../shared/widgets/x_card.dart';
import '../../../shared/widgets/x_chip.dart';
import '../../../shared/widgets/x_common.dart';
import '../../../shared/widgets/x_icon.dart';
import '../../../shared/widgets/x_top_bar.dart';
import '../application/order_providers.dart';
import '../data/order_models.dart';
/// 金额展示:整数不带小数,否则保留两位。
String _money(num v) =>
v == v.roundToDouble() ? v.toStringAsFixed(0) : formatMoney(v);
/// 订单详情页,对应源 `pages/orderdetail/index.vue`。
///
/// **与源的差异(后端无对应字段)**:客户卡片省略评分与任务数,改为展示电话;
/// 服务地点只有一行地址(后端无 `addressDetail`);现场照片为空时只显示「添加」。
class OrderDetailPage extends ConsumerWidget {
const OrderDetailPage({super.key, required this.source, required this.id});
/// repair | install | maintenance | survey
final String source;
final int id;
@override
Widget build(BuildContext context, WidgetRef ref) {
final detail = ref.watch(orderDetailProvider((source: source, id: id)));
return Scaffold(
body: XPageShell(
backgroundColor: AppColors.surfaceContainerLow,
topBar: XTopBar.nav(
title: '订单详情',
onBack: () => context.pop(),
trailing: GestureDetector(
onTap: () => showAppToast('分享功能'),
behavior: HitTestBehavior.opaque,
child: const Padding(
padding: EdgeInsets.all(8),
child: XIcon('share', size: 24, color: AppColors.primary),
),
),
),
child: Stack(
children: [
Positioned.fill(
child: switch (detail) {
AsyncData(:final value) => _buildContent(context, value),
AsyncError(:final error) => XPlaceholder(text: '订单加载失败\n$error'),
_ => const Center(
child: SizedBox(
width: 26,
height: 26,
child: CircularProgressIndicator(
strokeWidth: 2.4,
valueColor: AlwaysStoppedAnimation(AppColors.primary),
),
),
),
},
),
// 底部固定操作栏,对应源 `.bottom-action`
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _BottomAction(
onStart: () => showAppToast('已开始出发', success: true),
),
),
],
),
),
);
}
Widget _buildContent(BuildContext context, Order order) {
return SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 120),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_MapSection(address: order.address),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_JobCard(order: order),
const SizedBox(height: 12),
_CustomerCard(order: order),
const SizedBox(height: 16),
_PhotosSection(photos: order.photos),
const SizedBox(height: 16),
_FinancialCard(order: order),
const SizedBox(height: 16),
_LocationCard(address: order.address),
],
),
),
],
),
);
}
}
/// 顶部地图占位(176 高)+ 右下角「导航」按钮。
class _MapSection extends StatelessWidget {
const _MapSection({required this.address});
final String address;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 176,
child: Stack(
children: [
const Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFDDE1FF), Color(0xFFF4F3F8)],
),
),
),
),
const Positioned.fill(
child: CustomPaint(painter: _DotPatternPainter()),
),
Center(
child: XIcon(
'location',
size: 48,
color: AppColors.primary.withValues(alpha: 0.6),
),
),
Positioned(
right: 16,
bottom: 12,
child: GestureDetector(
onTap: () => showAppToast('打开导航'),
behavior: HitTestBehavior.opaque,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 10,
),
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(25),
boxShadow: const [
BoxShadow(
color: Color(0x4D0052FF),
blurRadius: 12,
offset: Offset(0, 4),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
XIcon('navigation', size: 20, color: Colors.white),
SizedBox(width: 8),
Text(
'导航',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
),
),
),
],
),
);
}
}
/// 工单卡片,对应源 `.job-card`。
class _JobCard extends StatelessWidget {
const _JobCard({required this.order});
final Order order;
@override
Widget build(BuildContext context) {
return XCard(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
'JOB ID: #${order.jobId}',
style: AppTypography.labelCaps.copyWith(
color: AppColors.textMuted,
),
),
),
Text(
order.title,
style: AppTypography.headlineMd.copyWith(
color: AppColors.onSurface,
),
),
],
),
),
const SizedBox(width: 12),
XTypeChip(
type: order.type,
text: order.detailTypeText,
compact: false,
),
],
),
const SizedBox(height: 16),
// 描述块
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.surfaceContainerLow,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.borderSoft),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(top: 2),
child: XIcon('warning', size: 20, color: AppColors.error),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'"${order.description}"',
style: AppTypography.bodyMd.copyWith(
color: AppColors.onSurfaceVariant,
height: 1.5,
),
),
),
],
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _MetaItem(label: '预约时间', value: order.schedule),
),
Container(
width: 1,
height: 40,
color: AppColors.borderSoft,
),
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 16),
child: _MetaItem(
label: '预计时长',
value: order.estimatedDuration,
),
),
),
],
),
],
),
);
}
}
class _MetaItem extends StatelessWidget {
const _MetaItem({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
label,
style: const TextStyle(fontSize: 12, color: AppColors.textMuted),
),
),
Text(
value.isEmpty ? '-' : value,
style: AppTypography.bodyLg.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.onSurface,
),
),
],
);
}
}
/// 客户卡片,对应源 `.customer-card`。
class _CustomerCard extends StatelessWidget {
const _CustomerCard({required this.order});
final Order order;
@override
Widget build(BuildContext context) {
return XCard(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: Row(
children: [
// 后端无客户头像,用姓名首字占位(与「我的」页头像一致)
Container(
width: 48,
height: 48,
decoration: const BoxDecoration(
color: AppColors.surfaceContainerHighest,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Text(
order.customerAvatarText,
style: AppTypography.bodyLg.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.onSurfaceVariant,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
order.customerName.isEmpty ? '未知客户' : order.customerName,
style: AppTypography.bodyLg.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.onSurface,
),
),
const SizedBox(height: 2),
Text(
order.customerPhone.isEmpty
? '暂无联系方式'
: order.customerPhone,
style: AppTypography.bodyMd.copyWith(
color: AppColors.textMuted,
),
),
],
),
),
],
),
),
const SizedBox(width: 12),
GestureDetector(
onTap: () => showAppToast('拨打电话'),
behavior: HitTestBehavior.opaque,
child: Container(
width: 40,
height: 40,
decoration: const BoxDecoration(
color: AppColors.primary,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: const XIcon('call', size: 18, color: Colors.white),
),
),
],
),
);
}
}
/// 现场照片横向列表。
class _PhotosSection extends StatelessWidget {
const _PhotosSection({required this.photos});
final List<String> photos;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
'现场照片',
style: AppTypography.bodySm.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.textMuted,
letterSpacing: 0.26,
),
),
),
SizedBox(
height: 128,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
for (final url in photos) ...[
ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.lg),
child: Image.network(
url,
width: 128,
height: 128,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => Container(
width: 128,
height: 128,
color: AppColors.surfaceContainerHigh,
child: const XIcon(
'camera',
size: 28,
color: AppColors.textMuted,
),
),
),
),
const SizedBox(width: 12),
],
GestureDetector(
onTap: () => showAppToast('添加现场照片'),
behavior: HitTestBehavior.opaque,
child: Container(
width: 128,
height: 128,
decoration: BoxDecoration(
color: AppColors.surfaceContainerLowest,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(
color: AppColors.textPlaceholder,
width: 2,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
XIcon('add-photo', size: 32, color: AppColors.primary),
SizedBox(height: 4),
Text(
'添加',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.primary,
),
),
],
),
),
),
],
),
),
],
);
}
}
/// 费用卡片,对应源 `.financial-card`。
class _FinancialCard extends StatelessWidget {
const _FinancialCard({required this.order});
final Order order;
@override
Widget build(BuildContext context) {
return XCard(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
'预计收益',
style: AppTypography.bodyLg.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.onSurface,
),
),
),
_row('人工费', '¥${_money(order.laborFee)}'),
const SizedBox(height: 12),
_row('预计材料费', '¥${_money(order.materialFee)}'),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.only(top: 12),
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: AppColors.borderSoft)),
),
child: Row(
children: [
Text(
'总计预估',
style: AppTypography.bodyLg.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.onSurface,
),
),
const Spacer(),
Text(
'¥${_money(order.amount)}',
style: AppTypography.titleLg.copyWith(
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
],
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.surfaceContainerLow,
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const XIcon('info', size: 18, color: AppColors.textMuted),
const SizedBox(width: 10),
Expanded(
child: Text(
'最终金额将根据现场核查和实际材料使用情况而定。',
style: AppTypography.labelCaps.copyWith(
color: AppColors.textMuted,
height: 1.5,
),
),
),
],
),
),
],
),
);
}
Widget _row(String label, String value) => Row(
children: [
Text(
label,
style: AppTypography.bodyMd.copyWith(color: AppColors.onSurfaceVariant),
),
const Spacer(),
Text(
value,
style: AppTypography.bodyMd.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
],
);
}
/// 服务地点卡片。
class _LocationCard extends StatelessWidget {
const _LocationCard({required this.address});
final String address;
@override
Widget build(BuildContext context) {
return XCard(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
'服务地点',
style: AppTypography.bodySm.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.textMuted,
letterSpacing: 0.26,
),
),
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(top: 2),
child: XIcon('location', size: 20, color: AppColors.primary),
),
const SizedBox(width: 12),
Expanded(
child: Text(
address.isEmpty ? '地址待确认' : address,
style: AppTypography.bodyLg.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.onSurface,
),
),
),
],
),
],
),
);
}
}
/// 底部固定「开始出发」按钮。
class _BottomAction extends StatelessWidget {
const _BottomAction({required this.onStart});
final VoidCallback onStart;
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.paddingOf(context).bottom,
),
decoration: const BoxDecoration(
color: AppColors.headerGlass,
border: Border(top: BorderSide(color: AppColors.borderSoft)),
),
child: GestureDetector(
onTap: onStart,
behavior: HitTestBehavior.opaque,
child: Container(
height: 54,
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(14),
boxShadow: const [
BoxShadow(
color: Color(0x4D0052FF),
blurRadius: 12,
offset: Offset(0, 4),
),
],
),
alignment: Alignment.center,
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
XIcon('bolt', size: 24, color: Colors.white),
SizedBox(width: 10),
Text(
'开始出发',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
),
),
);
}
}
/// 源 `.map-bg::before` 的圆点纹理(100×100 平铺,品牌蓝 8% 透明度)。
class _DotPatternPainter extends CustomPainter {
const _DotPatternPainter();
static const _dots = <(double, double, double)>[
(11, 11, 7),
(59, 36, 7),
(16, 33, 3),
(79, 64, 3),
(34, 87, 3),
(90, 11, 3),
(12, 82, 4),
(40, 17, 4),
(63, 5, 5),
(57, 66, 4),
(86, 87, 5),
(32, 58, 5),
(89, 45, 5),
(80, 27, 2),
(60, 89, 2),
(35, 39, 2),
(12, 58, 2),
];
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = const Color(0x140052FF);
for (var ox = 0.0; ox < size.width; ox += 100) {
for (var oy = 0.0; oy < size.height; oy += 100) {
for (final (cx, cy, r) in _dots) {
canvas.drawCircle(Offset(ox + cx, oy + cy), r, paint);
}
}
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
@@ -0,0 +1,647 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/network/api_response.dart';
import '../../../core/providers.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/utils/format_util.dart';
import '../../../shared/widgets/app_feedback.dart';
import '../../../shared/widgets/x_chip.dart';
import '../../../shared/widgets/x_common.dart';
import '../../../shared/widgets/x_icon.dart';
import '../../../shared/widgets/x_top_bar.dart';
import '../application/order_providers.dart';
import '../data/order_models.dart';
/// 金额展示:整数不带小数,否则保留两位(源 mock 里金额是整数)。
String _money(num v) =>
v == v.roundToDouble() ? v.toStringAsFixed(0) : formatMoney(v);
/// 订单 Tab(待接订单),对应源 `pages/order/index.vue`。
///
/// 顶栏与地图区固定,只有下方订单列表滚动;沿用源 `.orders-section`
/// 24px 顶部圆角「上移压住地图」的观感(把地图高度相应减 24,视觉等价)。
class OrderTab extends ConsumerStatefulWidget {
const OrderTab({super.key});
@override
ConsumerState<OrderTab> createState() => _OrderTabState();
}
class _OrderTabState extends ConsumerState<OrderTab> {
/// 本会话内已接单的订单 id(接单后列表会刷新移除,这里用于渲染「已接单」态)。
final _grabbed = <String>{};
String? _grabbing;
Future<void> _handleGrab(Order order) async {
if (_grabbing != null || _grabbed.contains(order.id)) return;
setState(() => _grabbing = order.id);
try {
final res = await ref
.read(orderApiProvider)
.grab(order.source, order.intId);
if (!mounted) return;
if (res.isOk) {
setState(() => _grabbed.add(order.id));
showAppToast('接单成功', success: true);
ref.invalidate(orderPoolProvider);
} else {
showAppToast(res.message.isEmpty ? '接单失败' : res.message);
}
} on ApiException catch (e) {
if (mounted) showAppToast(e.message);
} finally {
if (mounted) setState(() => _grabbing = null);
}
}
@override
Widget build(BuildContext context) {
final pool = ref.watch(orderPoolProvider);
final unread = ref.watch(noticeUnreadCountProvider);
return XPageShell(
topBar: XTopBar.brand(
height: 64,
logoSize: 28,
titleSize: 20,
titleWeight: FontWeight.w700,
trailingBadge: unread,
onTrailingTap: () => context.push('/notice'),
),
child: RefreshIndicator(
color: AppColors.primary,
onRefresh: () async {
ref.invalidate(orderPoolProvider);
await ref.read(orderPoolProvider.future);
},
child: ListView(
padding: EdgeInsets.zero,
physics: const AlwaysScrollableScrollPhysics(),
children: [
// 源地图高 320,被下方 24px 圆角区块遮住底部 → 这里直接取 296
SizedBox(height: 296, child: const _MapPlaceholder()),
Container(
decoration: const BoxDecoration(
color: AppColors.bgPage,
borderRadius: BorderRadius.vertical(
top: Radius.circular(AppRadius.xxl),
),
),
padding: const EdgeInsets.fromLTRB(16, 24, 16, 120),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildSectionHeader(pool),
const SizedBox(height: 16),
..._buildBody(pool),
],
),
),
],
),
),
);
}
Widget _buildSectionHeader(AsyncValue<List<Order>> pool) {
final count = pool.value?.length;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'待接订单',
style: AppTypography.navLargeTitle.copyWith(
color: AppColors.onSurface,
),
),
const SizedBox(height: 4),
Text(
count == null ? '正在获取附近订单…' : '附近有 $count 个订单',
style: AppTypography.bodyMd.copyWith(
color: AppColors.onSurfaceVariant,
),
),
],
),
),
GestureDetector(
onTap: () => showAppToast('筛选功能'),
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
const Text(
'筛选',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: AppColors.primary,
),
),
const SizedBox(width: 6),
const XIcon('filter', size: 18, color: AppColors.primary),
],
),
),
),
],
);
}
List<Widget> _buildBody(AsyncValue<List<Order>> pool) {
if (pool.isLoading && !pool.hasValue) {
return const [
Padding(
padding: EdgeInsets.symmetric(vertical: 60),
child: Center(
child: SizedBox(
width: 26,
height: 26,
child: CircularProgressIndicator(
strokeWidth: 2.4,
valueColor: AlwaysStoppedAnimation(AppColors.primary),
),
),
),
),
];
}
if (pool.hasError && !pool.hasValue) {
return [
XPlaceholder(text: '订单加载失败\n${pool.error}'),
Center(
child: TextButton(
onPressed: () => ref.invalidate(orderPoolProvider),
child: const Text('点击重试'),
),
),
];
}
final orders = pool.value ?? const <Order>[];
if (orders.isEmpty) {
return const [XPlaceholder(text: '附近暂无待接订单')];
}
return [
for (final order in orders)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: _OrderCard(
order: order,
grabbing: _grabbing == order.id,
grabbed: _grabbed.contains(order.id),
onGrab: () => _handleGrab(order),
onTap: () => context.push('/order/${order.source}/${order.intId}'),
),
),
];
}
}
/// 单张待接订单卡片,对应源 `.order-card`。
class _OrderCard extends StatelessWidget {
const _OrderCard({
required this.order,
required this.grabbing,
required this.grabbed,
required this.onGrab,
required this.onTap,
});
final Order order;
final bool grabbing;
final bool grabbed;
final VoidCallback onGrab;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: AppColors.surfaceContainerLowest,
borderRadius: BorderRadius.circular(AppRadius.xl),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.xl),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: AppColors.borderSoft),
borderRadius: BorderRadius.circular(AppRadius.xl),
boxShadow: AppShadows.orderCard,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Row(
children: [
Text(
'#${order.displayNo}',
style: AppTypography.labelCaps.copyWith(
color: AppColors.textMuted,
),
),
const SizedBox(width: 12),
XTypeChip(type: order.type, text: order.listTypeText),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'¥${_money(order.amount)}',
style: AppTypography.headlineMd.copyWith(
color: AppColors.primary,
),
),
const Text(
'预估收益',
style: TextStyle(
fontSize: 11,
color: AppColors.textMuted,
),
),
],
),
],
),
const SizedBox(height: 12),
Text(
order.title,
style: AppTypography.headlineMd.copyWith(
color: AppColors.onSurface,
),
),
const SizedBox(height: 12),
Row(
children: [
const XIcon(
'location',
size: 16,
color: AppColors.onSurfaceVariant,
),
const SizedBox(width: 6),
Text(
'${order.distance}km',
style: AppTypography.bodyMd.copyWith(
color: AppColors.onSurfaceVariant,
),
),
const SizedBox(width: 24),
const XIcon(
'clock',
size: 16,
color: AppColors.onSurfaceVariant,
),
const SizedBox(width: 6),
Expanded(
child: Text(
order.schedule,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.bodyMd.copyWith(
color: AppColors.onSurfaceVariant,
),
),
),
],
),
const SizedBox(height: 16),
_buildGrabButton(),
],
),
),
),
);
}
Widget _buildGrabButton() {
final (Color bg, Color fg, BoxShadow? shadow, Widget icon) = switch ((
grabbing,
grabbed,
order.type,
)) {
(true, _, _) => (
AppColors.primary,
Colors.white,
_primaryShadow,
const _SpinningRefresh(),
),
(_, true, _) => (
AppColors.tertiaryFixed,
AppColors.onTertiaryFixedVariant,
null,
const XIcon('check', size: 20, color: AppColors.onTertiaryFixedVariant),
),
(_, _, 'urgent') => (
AppColors.primary,
Colors.white,
_primaryShadow,
const XIcon('forward', size: 20, color: Colors.white),
),
_ => (
AppColors.surfaceContainerLow,
AppColors.onSurface,
null,
const XIcon('forward', size: 20, color: AppColors.onSurface),
),
};
final label = grabbing
? '接单中...'
: grabbed
? '已接单'
: (order.type == 'urgent' ? '立即接单' : '接单');
final outlined = !grabbing && !grabbed && order.type != 'urgent';
return Opacity(
opacity: grabbing ? 0.7 : 1,
child: GestureDetector(
onTap: (grabbing || grabbed) ? null : onGrab,
behavior: HitTestBehavior.opaque,
child: Container(
height: 48,
alignment: Alignment.center,
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: outlined
? Border.all(color: const Color(0x80C3C5D9))
: null,
boxShadow: shadow == null ? null : [shadow],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
icon,
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: fg,
),
),
],
),
),
),
);
}
static const _primaryShadow = BoxShadow(
color: Color(0x4D0052FF),
blurRadius: 12,
offset: Offset(0, 4),
);
}
/// 接单中的旋转刷新图标。
class _SpinningRefresh extends StatefulWidget {
const _SpinningRefresh();
@override
State<_SpinningRefresh> createState() => _SpinningRefreshState();
}
class _SpinningRefreshState extends State<_SpinningRefresh>
with SingleTickerProviderStateMixin {
late final AnimationController _c = AnimationController(
vsync: this,
duration: const Duration(seconds: 1),
)..repeat();
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => RotationTransition(
turns: _c,
child: const XIcon('refresh', size: 20, color: Colors.white),
);
}
/// 地图占位:源 `.map-bg` 的圆点纹理 + 两个标记 + 右侧控制按钮。
class _MapPlaceholder extends StatelessWidget {
const _MapPlaceholder();
@override
Widget build(BuildContext context) {
return Stack(
children: [
const Positioned.fill(
child: ColoredBox(color: AppColors.surfaceContainerHighest),
),
const Positioned.fill(child: CustomPaint(painter: _DotPatternPainter())),
// 紧急标记(带脉冲光环)
const Positioned(
top: 40,
left: 60,
child: _PulsingMarker(),
),
// 普通标记
Positioned(
top: 120,
right: 80,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.primary,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: const [
BoxShadow(
color: Color(0x1A000000),
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: const XIcon('shandian', size: 18, color: Colors.white),
),
),
Positioned(
right: 16,
bottom: 48,
child: Column(
children: [
_MapControlButton(
icon: 'location',
onTap: () => showAppToast('定位中...'),
),
const SizedBox(height: 8),
_MapControlButton(
icon: 'map',
onTap: () => showAppToast('图层切换'),
),
],
),
),
],
);
}
}
/// 紧急标记:红色胶囊 + 呼吸光环,对应源 `.marker.urgent`。
class _PulsingMarker extends StatefulWidget {
const _PulsingMarker();
@override
State<_PulsingMarker> createState() => _PulsingMarkerState();
}
class _PulsingMarkerState extends State<_PulsingMarker>
with SingleTickerProviderStateMixin {
late final AnimationController _c = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat(reverse: true);
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.center,
children: [
AnimatedBuilder(
animation: _c,
builder: (context, _) => Container(
width: 76 + 24 * _c.value,
height: 32 + 24 * _c.value,
decoration: BoxDecoration(
color: AppColors.secondaryContainer.withValues(
alpha: 0.4 * (1 - _c.value),
),
borderRadius: BorderRadius.circular(AppRadius.full),
),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: AppColors.tertiaryContainer,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white, width: 2),
boxShadow: const [
BoxShadow(
color: Color(0x1A000000),
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
XIcon('shandian', size: 14, color: Colors.white),
SizedBox(width: 6),
Text(
'紧急',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
],
),
),
],
);
}
}
class _MapControlButton extends StatelessWidget {
const _MapControlButton({required this.icon, required this.onTap});
final String icon;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: const Color(0xE6FFFFFF),
borderRadius: BorderRadius.circular(AppRadius.lg),
boxShadow: const [
BoxShadow(
color: Color(0x14000000),
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
alignment: Alignment.center,
child: XIcon(icon, size: 20, color: AppColors.primary),
),
);
}
}
/// 源 `.map-bg::before` 的圆点纹理(100×100 平铺,品牌蓝 8% 透明度)。
class _DotPatternPainter extends CustomPainter {
const _DotPatternPainter();
/// 取自源 SVG 里的 (cx, cy, r) 列表。
static const _dots = <(double, double, double)>[
(11, 11, 7),
(59, 36, 7),
(16, 33, 3),
(79, 64, 3),
(34, 87, 3),
(90, 11, 3),
(12, 82, 4),
(40, 17, 4),
(63, 5, 5),
(57, 66, 4),
(86, 87, 5),
(32, 58, 5),
(89, 45, 5),
(80, 27, 2),
(60, 89, 2),
(35, 39, 2),
(12, 58, 2),
];
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = const Color(0x140052FF);
for (var ox = 0.0; ox < size.width; ox += 100) {
for (var oy = 0.0; oy < size.height; oy += 100) {
for (final (cx, cy, r) in _dots) {
canvas.drawCircle(Offset(ox + cx, oy + cy), r, paint);
}
}
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
@@ -0,0 +1,425 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/network/api_response.dart';
import '../../../core/providers.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_typography.dart';
import '../../../shared/widgets/app_feedback.dart';
import '../../../shared/widgets/x_card.dart';
import '../../../shared/widgets/x_common.dart';
import '../../../shared/widgets/x_icon.dart';
import '../../../shared/widgets/x_top_bar.dart';
import '../../auth/application/session_controller.dart';
import '../../auth/data/auth_models.dart';
/// 渐变卡内各级白色透明度,对应源 `rgba(255,255,255,x)`。
const _white80 = Color(0xCCFFFFFF);
const _white60 = Color(0x99FFFFFF);
const _white20 = Color(0x33FFFFFF);
const _white15 = Color(0x26FFFFFF);
const _white10 = Color(0x1AFFFFFF);
/// 个人资料接口。后端未提供独立 provider,故在此局部声明。
/// 源页面「我的」只展示一张证书,这里取 `WorkerProfile.primaryCert`。
final _profileProvider = FutureProvider.autoDispose<WorkerProfile>((ref) async {
final res = await ref.watch(authApiProvider).profile();
if (!res.isOk || res.data == null) throw ApiException(res.message);
return res.data!;
});
/// 「我的」Tab,逐像素复刻源 `src/pages/profile/index.vue`。
///
/// 顶栏由本页渲染;底部 Tab 栏由 `TabsPage` 提供。
/// 「专业技能」区块与「从业年限」统计项后端无对应字段,已按约定省略。
class ProfileTab extends ConsumerWidget {
const ProfileTab({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final session = ref.watch(sessionProvider);
final profileAsync = ref.watch(_profileProvider);
final unread = ref.watch(noticeUnreadCountProvider);
final profile = profileAsync.value;
final worker = profile?.basic ?? session.worker;
return XPageShell(
topBar: XTopBar.brand(
height: 56,
logoSize: 28,
titleSize: 18,
titleWeight: FontWeight.w600,
trailingBadge: unread,
onTrailingTap: () => context.push('/notice'),
),
child: worker == null
? _placeholder(profileAsync)
: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 96),
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 24, 16, 24),
child: XPageTitle(
title: '我的',
subtitle: '管理您的个人资料与账户设置',
),
),
_profileCard(worker, profile?.primaryCert),
_menuSection(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: _logoutButton(ref),
),
],
),
);
}
/// 无缓存资料时展示加载 / 错误态。
Widget _placeholder(AsyncValue<WorkerProfile> async) {
final text = async.when(
loading: () => '加载中...',
error: (error, _) => error.toString(),
data: (_) => '加载中...',
);
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [XPlaceholder(text: text)],
);
}
/// 资料卡:渐变底 + 头像 + 姓名 / ID + 证书区 + 两项统计。
Widget _profileCard(WorkerInfo worker, CertInfo? cert) {
final certType = cert?.certType ?? '';
final certTitle = certType.isEmpty ? '电工职业资格证' : certType;
return Container(
margin: const EdgeInsets.fromLTRB(16, 0, 16, 24),
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.primaryDeep, AppColors.primary],
),
borderRadius: BorderRadius.circular(AppRadius.xl),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
SizedBox(
width: 64,
height: 64,
child: Stack(
children: [
Container(
width: 64,
height: 64,
alignment: Alignment.center,
decoration: const BoxDecoration(
color: AppColors.onPrimary,
shape: BoxShape.circle,
),
child: Text(
worker.avatarText,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
),
Positioned(
right: 2,
bottom: 2,
child: Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: AppColors.success,
shape: BoxShape.circle,
border: Border.all(
color: AppColors.primary,
width: 2,
),
),
),
),
],
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
worker.displayName,
style: AppTypography.titleLg.copyWith(
color: AppColors.onPrimary,
letterSpacing: -0.44,
),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: _white20,
borderRadius: BorderRadius.circular(AppRadius.regular),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'ID',
style: TextStyle(
fontSize: 11,
color: _white80,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 6),
Text(
'${worker.id}',
style: const TextStyle(
fontSize: 13,
color: AppColors.onPrimary,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
),
],
),
const SizedBox(height: 20),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _white15,
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const XIcon('cert', size: 18, color: AppColors.onPrimary),
const SizedBox(width: 8),
Expanded(
child: Text(
certTitle,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.onPrimary,
),
),
),
_certBadge(worker),
],
),
const SizedBox(height: 12),
if (cert == null)
const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Text(
'暂无资质证照',
style: TextStyle(fontSize: 13, color: _white60),
),
)
else ...[
_certRow('证书编号', cert.certNo),
_certRow('有效期至', cert.expireDate, divider: false),
],
],
),
),
const SizedBox(height: 20),
Row(
children: [
Expanded(child: _statItem('${worker.completedCount}', '已完成订单')),
Container(width: 1, height: 40, color: _white20),
Expanded(
child: _statItem(worker.rating.toStringAsFixed(1), '评分'),
),
],
),
],
),
);
}
/// 审核通过显示绿色「已认证」,否则以弱化样式显示审核状态文案。
Widget _certBadge(WorkerInfo worker) {
final approved = worker.isApproved;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: approved ? AppColors.success : _white20,
borderRadius: BorderRadius.circular(6),
),
child: Text(
approved ? '已认证' : worker.auditStatusText,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: approved ? AppColors.onPrimary : _white80,
),
),
);
}
Widget _certRow(String label, String value, {bool divider = true}) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
border: divider ? const Border(bottom: BorderSide(color: _white10)) : null,
),
child: Row(
children: [
Text(label, style: const TextStyle(fontSize: 13, color: _white80)),
const SizedBox(width: 12),
Expanded(
child: Text(
value,
textAlign: TextAlign.right,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.onPrimary,
),
),
),
],
),
);
}
Widget _statItem(String value, String label) => Column(
children: [
Text(
value,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: AppColors.onPrimary,
letterSpacing: -0.48,
),
),
const SizedBox(height: 4),
Text(label, style: const TextStyle(fontSize: 12, color: _white80)),
],
);
/// 两组 `ios-card` 菜单,行点击仅提示(同源实现)。
Widget _menuSection() {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
child: Column(
children: [
_menuGroup(const [
('order', '我的订单'),
('earnings', '收益记录'),
('notify', '账户设置'),
]),
const SizedBox(height: 12),
_menuGroup(const [('star', '帮助中心'), ('shandian', '关于我们')]),
],
),
);
}
Widget _menuGroup(List<(String, String)> items) {
return XCard(
radius: AppRadius.lg,
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
children: [
for (var i = 0; i < items.length; i++) ...[
if (i > 0)
Container(
height: 1,
margin: const EdgeInsets.symmetric(horizontal: 16),
color: AppColors.borderSoft,
),
_menuRow(items[i].$1, items[i].$2),
],
],
),
);
}
Widget _menuRow(String icon, String label) {
return GestureDetector(
onTap: () => showAppToast(label),
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
XIcon(icon, size: 22, color: AppColors.onSurfaceVariant),
const SizedBox(width: 12),
Expanded(
child: Text(
label,
style: AppTypography.bodyLg.copyWith(
color: AppColors.onSurface,
),
),
),
const XIcon(
'arrow-right',
size: 16,
color: AppColors.textPlaceholder,
),
],
),
),
);
}
Widget _logoutButton(WidgetRef ref) {
return XCard(
radius: AppRadius.lg,
padding: EdgeInsets.zero,
onTap: () => _handleLogout(ref),
child: const SizedBox(
height: 48,
width: double.infinity,
child: Center(
child: Text(
'退出登录',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.tertiaryContainer,
),
),
),
),
);
}
Future<void> _handleLogout(WidgetRef ref) async {
final confirmed = await showAppConfirm(
title: '提示',
content: '确定要退出登录吗?',
confirmText: '确定',
cancelText: '取消',
);
if (!confirmed) return;
await ref.read(sessionProvider.notifier).logout();
}
}
+20
View File
@@ -0,0 +1,20 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app/app.dart';
import 'core/providers.dart';
import 'core/storage/token_storage.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// SharedPreferences 必须在 runApp 之前完成异步读取,再通过 override 注入。
final storage = await TokenStorage.open();
runApp(
ProviderScope(
overrides: [tokenStorageProvider.overrideWithValue(storage)],
child: const VoltLinkApp(),
),
);
}
+143
View File
@@ -0,0 +1,143 @@
import 'package:flutter/material.dart';
import '../../core/app_keys.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/theme/app_typography.dart';
/// `uni.showToast({ title, icon })` 的等价实现。
/// 不依赖调用方的 BuildContext,可在任意异步回调里安全使用。
void showAppToast(String title, {bool success = false}) {
final messenger = AppKeys.scaffoldMessenger.currentState;
if (messenger == null) return;
messenger
..clearSnackBars()
..showSnackBar(
SnackBar(
content: Row(
children: [
if (success) ...[
const Icon(
Icons.check_circle,
size: 18,
color: AppColors.success,
),
const SizedBox(width: 8),
],
Expanded(
child: Text(
title,
style: AppTypography.bodyMd.copyWith(color: AppColors.surface),
),
),
],
),
backgroundColor: AppColors.inverseSurface,
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.all(AppSpacing.md),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
),
duration: Duration(milliseconds: success ? 1500 : 2200),
),
);
}
/// `uni.showLoading({ title })` 的等价实现,返回关闭函数。
VoidCallback showAppLoading([String title = '加载中...']) {
final navigator = AppKeys.rootNavigator.currentState;
if (navigator == null) return () {};
var closed = false;
showDialog<void>(
context: navigator.context,
barrierDismissible: false,
builder: (_) => PopScope(
canPop: false,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
decoration: BoxDecoration(
color: const Color(0xCC000000),
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
width: 26,
height: 26,
child: CircularProgressIndicator(
strokeWidth: 2.4,
valueColor: AlwaysStoppedAnimation(Colors.white),
),
),
const SizedBox(height: 12),
Text(
title,
style: AppTypography.bodyMd.copyWith(color: Colors.white),
),
],
),
),
),
),
);
return () {
if (closed) return;
closed = true;
final current = AppKeys.rootNavigator.currentState;
if (current != null && current.canPop()) current.pop();
};
}
/// `uni.showModal({ title, content })` 的等价实现,返回用户是否点了确认。
Future<bool> showAppConfirm({
required String title,
required String content,
String confirmText = '确定',
String cancelText = '取消',
bool destructive = false,
}) async {
final navigator = AppKeys.rootNavigator.currentState;
if (navigator == null) return false;
final result = await showDialog<bool>(
context: navigator.context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surfaceContainerLowest,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.xl),
),
title: Text(
title,
textAlign: TextAlign.center,
style: AppTypography.bodyLg.copyWith(fontWeight: FontWeight.w700),
),
content: Text(
content,
textAlign: TextAlign.center,
style: AppTypography.bodyMd.copyWith(color: AppColors.textMuted),
),
actionsAlignment: MainAxisAlignment.spaceEvenly,
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: Text(
cancelText,
style: AppTypography.bodyLg.copyWith(color: AppColors.textMuted),
),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: Text(
confirmText,
style: AppTypography.bodyLg.copyWith(
color: destructive ? AppColors.tertiaryContainer : AppColors.primary,
),
),
),
],
),
);
return result ?? false;
}
+85
View File
@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import 'x_icon.dart';
/// 底部导航,对应 `components/CustomTabBar.vue`。
///
/// 高 64,毛玻璃底色 rgba(250,249,254,0.95),上边框 + 轻微上投影,
/// 三个 Tab 均分,激活态整体变品牌蓝。底部安全区一并覆盖。
class XBottomNav extends StatelessWidget {
const XBottomNav({super.key, required this.current, required this.onChanged});
/// `order` | `earnings` | `profile`
final String current;
final ValueChanged<String> onChanged;
static const items = <({String key, String label, String icon})>[
(key: 'order', label: '订单', icon: 'dingdan'),
(key: 'earnings', label: '收益', icon: 'shouyi'),
(key: 'profile', label: '我的', icon: 'wode'),
];
@override
Widget build(BuildContext context) {
final bottomInset = MediaQuery.paddingOf(context).bottom;
return Container(
padding: EdgeInsets.only(bottom: bottomInset),
decoration: const BoxDecoration(
color: AppColors.navGlass,
border: Border(top: BorderSide(color: AppColors.borderSoft)),
boxShadow: AppShadows.topBar,
),
child: SizedBox(
height: 64,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
for (final item in items)
GestureDetector(
onTap: () {
if (item.key == current) return;
onChanged(item.key);
},
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 6,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
XIcon(
item.icon,
size: 24,
color: item.key == current
? AppColors.primary
: AppColors.textMuted,
),
const SizedBox(height: 2),
Text(
item.label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: item.key == current
? AppColors.primary
: AppColors.textMuted,
),
),
],
),
),
),
],
),
),
),
);
}
}
+59
View File
@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
/// 各页面 `.ios-card` 的等价实现:白底 + 浅描边 + 圆角 + 极淡阴影。
class XCard extends StatelessWidget {
const XCard({
super.key,
required this.child,
this.padding,
this.margin,
this.radius = AppRadius.xl,
this.background = AppColors.surfaceContainerLowest,
this.borderColor = AppColors.borderSoft,
this.shadows = AppShadows.card,
this.onTap,
});
final Widget child;
final EdgeInsetsGeometry? padding;
final EdgeInsetsGeometry? margin;
final double radius;
final Color background;
final Color borderColor;
/// 订单卡片用 [AppShadows.orderCard](0 2px 8px),其余用 [AppShadows.card]。
final List<BoxShadow> shadows;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final r = BorderRadius.circular(radius);
final content = Container(
padding: padding,
decoration: BoxDecoration(
color: background,
border: Border.all(color: borderColor),
borderRadius: r,
boxShadow: shadows,
),
child: child,
);
return Padding(
padding: margin ?? EdgeInsets.zero,
child: onTap == null
? content
: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: r,
child: content,
),
),
);
}
}
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import '../../core/theme/app_colors.dart';
/// 订单类型标签。对应源里的 `.order-type` / `.job-type`:
/// - `urgent` 紧急:浅红底 + 深红字
/// - `standard` 标准 / `regular` 常规:浅灰底 + 深灰字
///
/// 列表页字号 10,详情页 13,用 [compact] 区分。
class XTypeChip extends StatelessWidget {
const XTypeChip({
super.key,
required this.type,
required this.text,
this.compact = true,
});
/// `urgent` | `standard` | `regular`
final String type;
final String text;
final bool compact;
@override
Widget build(BuildContext context) {
final urgent = type == 'urgent';
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: urgent ? AppColors.errorContainer : AppColors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(8),
),
child: Text(
text,
style: TextStyle(
fontSize: compact ? 10 : 13,
fontWeight: FontWeight.w600,
color: urgent
? (compact ? AppColors.onErrorContainer : AppColors.error)
: AppColors.onSurfaceVariant,
),
),
);
}
}
+190
View File
@@ -0,0 +1,190 @@
import 'package:flutter/material.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/theme/app_typography.dart';
/// 页面骨架:固定顶栏 + 内容区。对应源「根容器固定、滚动只发生在内容区」的布局。
class XPageShell extends StatelessWidget {
const XPageShell({
super.key,
required this.topBar,
required this.child,
this.backgroundColor = AppColors.bgPage,
});
final Widget topBar;
final Widget child;
final Color backgroundColor;
@override
Widget build(BuildContext context) {
return Container(
color: backgroundColor,
child: Column(
children: [topBar, Expanded(child: child)],
),
);
}
}
/// 页面大标题 + 副标题,对应 `.page-header` / `.section-title`(28px/w700)。
class XPageTitle extends StatelessWidget {
const XPageTitle({super.key, required this.title, this.subtitle});
final String title;
final String? subtitle;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: AppTypography.navLargeTitle.copyWith(
color: AppColors.onSurface,
),
),
if (subtitle != null) ...[
const SizedBox(height: 4),
Text(
subtitle!,
style: AppTypography.bodyMd.copyWith(
color: AppColors.onSurfaceVariant,
),
),
],
],
);
}
}
/// 空态 / 加载中占位。
class XPlaceholder extends StatelessWidget {
const XPlaceholder({
super.key,
required this.text,
this.padding = const EdgeInsets.symmetric(vertical: 60),
});
final String text;
final EdgeInsetsGeometry padding;
@override
Widget build(BuildContext context) {
return Padding(
padding: padding,
child: Center(
child: Text(
text,
textAlign: TextAlign.center,
style: AppTypography.bodyMd.copyWith(color: AppColors.textMuted),
),
),
);
}
}
/// 实心主按钮,对应 `.login-btn` / `.start-btn` 等。
class XPrimaryButton extends StatelessWidget {
const XPrimaryButton({
super.key,
required this.label,
required this.onPressed,
this.leading,
this.loading = false,
this.height = 48,
this.radius = AppRadius.lg,
this.background = AppColors.primary,
this.foreground = AppColors.onPrimary,
this.fontSize = 17,
this.shadows,
});
final String label;
final VoidCallback? onPressed;
final Widget? leading;
final bool loading;
final double height;
final double radius;
final Color background;
final Color foreground;
final double fontSize;
final List<BoxShadow>? shadows;
@override
Widget build(BuildContext context) {
final disabled = onPressed == null || loading;
return Opacity(
opacity: disabled && !loading ? 0.55 : 1,
child: SizedBox(
height: height,
width: double.infinity,
child: Material(
color: background,
borderRadius: BorderRadius.circular(radius),
child: InkWell(
onTap: disabled ? null : onPressed,
borderRadius: BorderRadius.circular(radius),
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(radius),
boxShadow: shadows,
),
child: loading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(foreground),
),
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (leading != null) ...[
leading!,
const SizedBox(width: 8),
],
Text(
label,
style: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.w600,
color: foreground,
),
),
],
),
),
),
),
),
);
}
}
/// 区块小标题(20/w600),对应 `.chart-title` / `.section-title`。
class XSectionTitle extends StatelessWidget {
const XSectionTitle(this.text, {super.key, this.trailing});
final String text;
final Widget? trailing;
@override
Widget build(BuildContext context) {
final title = Text(
text,
style: AppTypography.headlineMd.copyWith(color: AppColors.onSurface),
);
if (trailing == null) return title;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [title, trailing!],
);
}
}
+158
View File
@@ -0,0 +1,158 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../../core/theme/app_colors.dart';
/// 图标组件,对应 uni-app 版页面里的 `<text class="iconfont icon-xxx">`。
///
/// **为什么是混合方案**:源工程只提供了 8 个真实字形(`iconfont.ttf`),而页面里
/// 引用的 `icon-lightning` / `icon-location` / `icon-star` 等**在字体里并不存在**,
/// 源工程实际渲染为空白。为保证复刻后的界面可读可用,这里:
/// - 8 个真实字形继续用搬运过来的 `iconfont.ttf` 渲染,保持逐像素一致;
/// - 其余语义图标改用 SVG 路径绘制,线条风格与源设计语言保持一致。
class XIcon extends StatelessWidget {
const XIcon(
this.name, {
super.key,
this.size = 20,
this.color = AppColors.textMuted,
});
final String name;
final double size;
final Color color;
/// `iconfont.ttf` 中真实存在的字形 -> Unicode 码点(取自源 `iconfont.css`)。
static const Map<String, int> glyphs = {
'shandian': 0xe6a4,
'tongzhi': 0xe60a,
'qianbao': 0xe60b,
'yaoqing': 0x100a5,
'dingdan': 0xe62a,
'weixin': 0xe665,
'wode': 0xe607,
'shouyi': 0x10186,
};
/// 语义图标的 SVG 路径(24×24 视图框,`currentColor` 会被替换成实际色值)。
static const Map<String, String> paths = {
'bolt': '<path d="M13 2L4.5 13.5H11l-2 8.5L19 10h-6.5z"/>',
'location':
'<path d="M12 2C8.1 2 5 5.1 5 9c0 5.2 7 13 7 13s7-7.8 7-13c0-3.9-3.1-7-7-7z"/><circle cx="12" cy="9" r="2.5"/>',
'clock':
'<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3.5 2" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/>',
'filter': '<path d="M4 5h16l-7 8v5l-2 1v-6z"/>',
'map':
'<path d="M9 4L3 7v13l6-3 6 3 6-3V4l-6 3z"/><path d="M9 4v13M15 7v13" stroke="currentColor" stroke-width="1.5" fill="none"/>',
'refresh':
'<path d="M20 11a8 8 0 1 0-1.5 5.6" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/><path d="M20 5v6h-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
'check':
'<path d="M4 12.5l5 5L20 6.5" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
'forward':
'<path d="M9 5l7 7-7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
'back':
'<path d="M15 5l-7 7 7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
'arrow-down':
'<path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
'star': '<path d="M12 3l2.9 5.9 6.5.9-4.7 4.6 1.1 6.5L12 17.8 6.2 20.9l1.1-6.5L2.6 9.8l6.5-.9z"/>',
'verified':
'<path d="M12 2l2.5 2.5 3.5-.5 1 3.4 3 2-1 3.6 1 3.6-3 2-1 3.4-3.5-.5L12 22l-3.5-1-3.5.5-.5-3-3-2 1-4-.5-3H3l3-2 1-3.4 3.5.5z"/><path d="M8 12l3 3 5-6" stroke="#ffffff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
'notification':
'<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zM18 16v-5.5c0-3.1-1.6-5.6-4.5-6.3V3.5a1.5 1.5 0 0 0-3 0v.7C7.6 4.9 6 7.4 6 10.5V16l-2 2v1h16v-1l-2-2z"/>',
'share':
'<path d="M18 16.1c-.8 0-1.5.3-2 .8l-7.1-4.1c.1-.3.1-.5.1-.8s0-.5-.1-.8l7-4.1c.5.5 1.3.8 2.1.8 1.7 0 3-1.3 3-3s-1.3-3-3-3-3 1.3-3 3c0 .3 0 .5.1.8L8.1 9.8C7.6 9.3 6.9 9 6 9c-1.7 0-3 1.3-3 3s1.3 3 3 3c.9 0 1.6-.3 2.1-.8l7.1 4.1c-.1.2-.1.5-.1.7 0 1.6 1.3 3 3 3s3-1.3 3-3-1.4-2.9-3.1-2.9z"/>',
'navigation':
'<path d="M12 2l10 10-10 10L2 12z"/><path d="M12 8.6l3.4 3.4-3.4 3.4" stroke="#ffffff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
'call':
'<path d="M6.6 3c.6 0 1.2.4 1.4 1l1 3c.2.5.1 1.1-.3 1.5L7 9.6c1 1.9 2.5 3.4 4.4 4.4l1.1-1.7c.4-.4 1-.5 1.5-.3l3 1c.6.2 1 .8 1 1.4V18c0 1-1 2-2 2C8 20 4 16 4 5c0-1 1-2 2-2z"/>',
'add-photo':
'<path d="M4 8h3l1.5-2h5L15 8h3a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2z"/><circle cx="10" cy="13.5" r="3"/><path d="M19 2v5M16.5 4.5h5" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/>',
'info':
'<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 11v6M12 7.4v.2" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/>',
'warning': '<path d="M12 3L2 20h20zM12 9v5M12 17.5v.1"/>',
'wallet':
'<rect x="3" y="6" width="18" height="13" rx="2.5"/><path d="M3 10.5h18" stroke="#ffffff" stroke-width="1.6" fill="none"/><circle cx="16.6" cy="14.4" r="1.3" fill="#ffffff"/>',
'list':
'<rect x="4" y="3" width="16" height="18" rx="2.5"/><path d="M8 8h8M8 12h8M8 16h5" stroke="#ffffff" stroke-width="1.8" stroke-linecap="round" fill="none"/>',
'settings':
'<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></g>',
'trend-up':
'<path d="M4 17l5-5 3 3 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/><path d="M13 9h5v5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
'close':
'<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/>',
'person': '<circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.5-6.5 8-6.5s8 2.5 8 6.5v1H4z"/>',
'camera':
'<rect x="3" y="6" width="18" height="14" rx="2"/><path d="M8 6l1.5-2h5L16 6"/><circle cx="12" cy="13" r="3.5"/>',
};
/// 源里两种命名风格混用(uni-app 的 `icon-xxx` 与 Material Symbols 名),
/// 这里统一做别名收敛,调用方两种写法都能渲染。
static const Map<String, String> _aliases = {
'lightning': 'bolt',
'time': 'clock',
'cert': 'verified',
'notify': 'notification',
'arrow-right': 'forward',
'order': 'list',
'earnings': 'wallet',
'chevron_left': 'back',
'location_on': 'location',
'directions': 'navigation',
'add_a_photo': 'add-photo',
};
static final Map<String, String> _svgCache = {};
static String _hex(Color color) =>
'#${(color.toARGB32() & 0xFFFFFF).toRadixString(16).padLeft(6, '0')}';
/// 归一化后的语义名:别名 -> 目标名。
static String resolve(String name) => _aliases[name] ?? name;
/// 生成 (图标, 颜色) 组合对应的 SVG 字符串,带缓存避免重复拼接。
static String svgFor(String name, Color color) {
final key = '$name|${color.toARGB32()}';
return _svgCache.putIfAbsent(key, () {
final body = (paths[resolve(name)] ?? paths['close']!).replaceAll(
'currentColor',
_hex(color),
);
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" '
'fill="${_hex(color)}">$body</svg>';
});
}
@override
Widget build(BuildContext context) {
final glyph = glyphs[name];
if (glyph != null) {
// 源里的彩色字形:直接交给 iconfont.ttf 渲染,保留原字形配色。
return SizedBox(
width: size,
height: size,
child: Center(
child: Text(
String.fromCharCode(glyph),
style: TextStyle(
fontFamily: 'iconfont',
fontSize: size,
height: 1,
color: color,
),
),
),
);
}
return SizedBox(
width: size,
height: size,
child: SvgPicture.string(
svgFor(name, color),
width: size,
height: size,
fit: BoxFit.contain,
),
);
}
}
+214
View File
@@ -0,0 +1,214 @@
import 'package:flutter/material.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/theme/app_typography.dart';
import 'x_icon.dart';
/// 固定顶栏,对应各页面 `.header.ios-blur` + `.header-content`。
///
/// 源里三种形态:
/// - 订单页 [XTopBar.brand]:高 64,闪电 + VoltLink(20/w700) + 通知
/// - 收益/我的 [XTopBar.brand]:高 56,闪电 + VoltLink(18/w600) + 通知
/// - 订单详情 [XTopBar.nav]:高 44,返回 + 居中标题 + 分享
///
/// 顶栏自带状态栏内边距,毛玻璃底色与源 `rgba(250,249,254,0.85)` 一致。
class XTopBar extends StatelessWidget {
const XTopBar._({
required this.height,
required this.leading,
required this.title,
required this.trailing,
required this.centerTitle,
});
/// 品牌顶栏(一级 Tab 页)。
///
/// [trailingBadge] 大于 0 时在铃铛右上角显示未读红点角标(>99 显示 99+)。
factory XTopBar.brand({
Key? key,
double height = 64,
double logoSize = 28,
double titleSize = 20,
FontWeight titleWeight = FontWeight.w700,
String title = 'VoltLink',
String trailingIcon = 'tongzhi',
VoidCallback? onTrailingTap,
int trailingBadge = 0,
}) {
return XTopBar._(
height: height,
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
XIcon('shandian', size: logoSize, color: AppColors.primary),
const SizedBox(width: 8),
Text(
title,
style: TextStyle(
fontSize: titleSize,
fontWeight: titleWeight,
color: AppColors.onSurface,
letterSpacing: -0.2,
),
),
],
),
title: '',
centerTitle: false,
trailing: GestureDetector(
onTap: onTrailingTap,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.all(8),
child: _TrailingIcon(icon: trailingIcon, badge: trailingBadge),
),
),
);
}
/// 二级页顶栏:返回 + 居中标题 + 右侧操作位。
factory XTopBar.nav({
Key? key,
required String title,
double height = 44,
VoidCallback? onBack,
Widget? trailing,
}) {
return XTopBar._(
height: height,
centerTitle: true,
title: title,
leading: GestureDetector(
onTap: onBack,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const XIcon('chevron_left', size: 26, color: AppColors.primary),
Text(
'返回',
style: AppTypography.bodyLg.copyWith(
color: AppColors.primary,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
trailing: trailing ?? const SizedBox.shrink(),
);
}
final double height;
final Widget leading;
final String title;
final Widget trailing;
final bool centerTitle;
@override
Widget build(BuildContext context) {
final topInset = MediaQuery.paddingOf(context).top;
return Container(
padding: EdgeInsets.only(top: topInset),
decoration: const BoxDecoration(
color: AppColors.headerGlass,
border: Border(bottom: BorderSide(color: AppColors.borderSoft)),
boxShadow: AppShadows.topBar,
),
child: SizedBox(
height: height,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: centerTitle
? Row(
children: [
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: leading,
),
),
Text(
title,
style: AppTypography.bodyLg.copyWith(
fontWeight: FontWeight.w600,
),
),
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: trailing,
),
),
],
)
: Row(
children: [
leading,
const Spacer(),
trailing,
],
),
),
),
);
}
}
/// 顶栏右侧图标 + 可选未读角标。
class _TrailingIcon extends StatelessWidget {
const _TrailingIcon({required this.icon, required this.badge});
final String icon;
final int badge;
@override
Widget build(BuildContext context) {
final base = XIcon(icon, size: 24, color: AppColors.onSurfaceVariant);
if (badge <= 0) return base;
return Stack(
clipBehavior: Clip.none,
children: [
base,
Positioned(top: -3, right: -6, child: _UnreadBadge(count: badge)),
],
);
}
}
/// 未读红色角标:显示条数,超过 99 显示 `99+`。
class _UnreadBadge extends StatelessWidget {
const _UnreadBadge({required this.count});
final int count;
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(minWidth: 16),
height: 16,
padding: const EdgeInsets.symmetric(horizontal: 4),
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.error,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.onPrimary, width: 1.5),
),
child: Text(
count > 99 ? '99+' : '$count',
style: const TextStyle(
fontSize: 10,
height: 1,
fontWeight: FontWeight.w600,
color: AppColors.onPrimary,
),
),
);
}
}