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