From 411ec8be23bedd422332e9d0ba1370c701d1494f Mon Sep 17 00:00:00 2001
From: andyesfly <35127884@qq.com>
Date: Wed, 21 Oct 2026 11:58:36 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=AE=A2=E5=8D=95?=
=?UTF-8?q?=E6=8B=A8=E5=8F=B7=E5=8A=9F=E8=83=BD=EF=BC=8C=E9=87=8D=E6=9E=84?=
=?UTF-8?q?=E8=AE=A2=E5=8D=95=E9=A1=B5=E4=B8=8E=E7=99=BB=E5=BD=95=E9=A1=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
android/app/src/main/AndroidManifest.xml | 10 +-
ios/Runner/Info.plist | 6 +
lib/core/env.dart | 8 +-
.../auth/presentation/login_page.dart | 658 ++++++++----------
lib/features/order/data/order_models.dart | 28 +
.../order/presentation/order_detail_page.dart | 44 +-
.../order/presentation/order_tab.dart | 538 +++++++++++---
pubspec.lock | 64 ++
pubspec.yaml | 1 +
9 files changed, 875 insertions(+), 482 deletions(-)
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 4d14762..9c9398e 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -1,8 +1,11 @@
+
+
+ android:icon="@mipmap/ic_launcher"
+ android:usesCleartextTraffic="true">
+
+
+
+
+
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 8f03d66..443847e 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -2,6 +2,12 @@
+ NSAppTransportSecurity
+
+
+ NSAllowsArbitraryLoads
+
+
CADisableMinimumFrameDurationOnPhone
NSCameraUsageDescription
diff --git a/lib/core/env.dart b/lib/core/env.dart
index 1cfc91c..f9d8ef5 100644
--- a/lib/core/env.dart
+++ b/lib/core/env.dart
@@ -7,9 +7,13 @@
/// 解析顺序见 [resolveBaseUrl]。
abstract final class Env {
/// 编译期默认地址,作为兜底方案。
+ ///
+ /// 默认值指向当前开发机后端(本机实测 7001 可用)。局域网 IP 变化后,
+ /// 登录页右上角「服务端设置」可直接改,或启动时用
+ /// `--dart-define=API_BASE_URL=http://:7001/api` 覆盖。
static const String apiBaseUrl = String.fromEnvironment(
'API_BASE_URL',
- defaultValue: 'http://192.168.3.29:7001/api',
+ defaultValue: 'http://192.168.21.184:7001/api',
);
static final Uri _default = Uri.parse(apiBaseUrl);
@@ -17,7 +21,7 @@ abstract final class Env {
/// 默认主机,取自 [apiBaseUrl]。
static String get defaultHost => _default.host;
- /// 默认端口,取自 [apiBaseUrl],未显式指定时回落 7007。
+ /// 默认端口,取自 [apiBaseUrl],未显式指定时回落 7001。
static int get defaultPort => _default.hasPort ? _default.port : 7001;
/// 由 IP + 端口拼接 Base URL;协议与路径沿用 [apiBaseUrl],
diff --git a/lib/features/auth/presentation/login_page.dart b/lib/features/auth/presentation/login_page.dart
index 1ace9a8..913bee0 100644
--- a/lib/features/auth/presentation/login_page.dart
+++ b/lib/features/auth/presentation/login_page.dart
@@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/env.dart';
+import '../../../core/network/api_response.dart';
import '../../../core/providers.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
@@ -20,10 +21,11 @@ import '../application/session_controller.dart';
/// `--dart-define=ENABLE_SERVER_SETTINGS=true` 打开。
const _serverSettingsEnabled = bool.fromEnvironment('ENABLE_SERVER_SETTINGS');
-/// 登录页,对应源 `pages/login/index.vue`。
+/// 登录页:手机号 + 密码。
///
-/// **与源的差异(已与用户确认)**:源为「手机号 + 验证码」,后端只支持
-/// 「手机号 + 密码」,故此处改为手机号 + 密码,并新增注册入口。
+/// 后端 `/worker-app/auth/login` 只支持手机号 + 密码(源 uni-app 的
+/// 「手机号 + 验证码」已按后端能力调整)。页面只保留登录必需元素,
+/// 注册入口作为次要操作放在卡片底部。
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@@ -35,18 +37,10 @@ class _LoginPageState extends ConsumerState {
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();
@@ -54,18 +48,31 @@ class _LoginPageState extends ConsumerState {
super.dispose();
}
- void _onChanged() => setState(() {});
-
- bool get _canLogin =>
- _phone.text.length == 11 && _password.text.length >= 6 && _agree;
-
Future _handleLogin() async {
- if (!_canLogin) {
- if (!_agree) _error = '请先阅读并同意服务协议与隐私政策';
- setState(() {});
+ if (_loading) return;
+
+ final phone = _phone.text.trim();
+ final password = _password.text;
+
+ // 按钮始终可点,校验失败给出明确提示,避免「点了没反应」。
+ if (phone.isEmpty) {
+ setState(() => _error = '请输入手机号');
+ return;
+ }
+ if (phone.length != 11) {
+ setState(() => _error = '请输入 11 位手机号');
+ return;
+ }
+ if (password.isEmpty) {
+ setState(() => _error = '请输入密码');
+ return;
+ }
+ if (password.length < 6) {
+ setState(() => _error = '密码至少 6 位');
return;
}
+ FocusScope.of(context).unfocus();
setState(() {
_loading = true;
_error = '';
@@ -73,13 +80,22 @@ class _LoginPageState extends ConsumerState {
try {
await ref
.read(sessionProvider.notifier)
- .login(phone: _phone.text.trim(), password: _password.text);
+ .login(phone: phone, password: password);
if (!mounted) return;
context.go('/tabs');
} on AuthException catch (e) {
+ // 后端返回的业务错误(如手机号或密码错误)
if (mounted) setState(() => _error = e.message);
+ } on ApiException catch (e) {
+ if (!mounted) return;
+ final base = ref.read(apiBaseUrlProvider);
+ setState(
+ () => _error = kDebugMode
+ ? '无法连接服务端:${e.message}\n$base'
+ : '网络异常,请检查网络后重试',
+ );
} catch (_) {
- if (mounted) setState(() => _error = '网络异常,请检查服务端地址');
+ if (mounted) setState(() => _error = '网络异常,请检查网络后重试');
} finally {
if (mounted) setState(() => _loading = false);
}
@@ -88,53 +104,57 @@ class _LoginPageState extends ConsumerState {
@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,
+ backgroundColor: AppColors.bgPage,
+ body: Stack(
+ children: [
+ Positioned.fill(
+ child: SafeArea(
+ child: LayoutBuilder(
+ builder: (context, constraints) => SingleChildScrollView(
+ padding: const EdgeInsets.fromLTRB(24, 32, 24, 32),
+ child: ConstrainedBox(
+ constraints: BoxConstraints(
+ minHeight: constraints.maxHeight - 64,
+ ),
+ child: Center(
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 400),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ _buildBrand(),
+ const SizedBox(height: 28),
+ _buildFormCard(),
+ const SizedBox(height: 20),
+ _buildAgreementHint(),
+ ],
+ ),
),
),
),
),
),
- ],
- ),
+ ),
+ ),
+ // 测试用「服务端设置」入口:调试版常显,release 需显式开关
+ if (kDebugMode || _serverSettingsEnabled)
+ Positioned(
+ top: 0,
+ right: 0,
+ child: SafeArea(
+ child: IconButton(
+ onPressed: _openServerSettings,
+ tooltip: '服务端设置',
+ icon: const XIcon(
+ 'settings',
+ size: 20,
+ color: AppColors.textMuted,
+ ),
+ ),
+ ),
+ ),
+ ],
),
);
}
@@ -151,331 +171,175 @@ class _LoginPageState extends ConsumerState {
// ---- 各区块 ----
- 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),
+ Widget _buildBrand() {
+ return Column(
+ children: [
+ Container(
+ width: 64,
+ height: 64,
+ decoration: BoxDecoration(
+ color: AppColors.primary,
+ borderRadius: BorderRadius.circular(18),
+ boxShadow: AppShadows.primaryButton,
),
- Padding(
- padding: const EdgeInsets.only(bottom: 8),
- child: Text(
- 'VoltLink',
- style: AppTypography.displayMd.copyWith(
- color: AppColors.onSurface,
- ),
- ),
+ alignment: Alignment.center,
+ child: const XIcon('shandian', size: 34, color: Colors.white),
+ ),
+ const SizedBox(height: 16),
+ Text(
+ 'VoltLink',
+ style: AppTypography.navLargeTitle.copyWith(
+ color: AppColors.onSurface,
),
- const Text(
- '欢迎回到 VoltLink',
- style: TextStyle(fontSize: 16, color: AppColors.textMuted),
- ),
- ],
- ),
+ ),
+ const SizedBox(height: 6),
+ const Text(
+ '电网作业电工端',
+ style: TextStyle(fontSize: 14, 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() {
+ Widget _buildFormCard() {
return Container(
- height: 56,
- decoration: const BoxDecoration(
- border: Border(bottom: BorderSide(color: AppColors.surfaceContainerHighest)),
+ padding: const EdgeInsets.fromLTRB(20, 24, 20, 20),
+ decoration: BoxDecoration(
+ color: AppColors.surfaceContainerLowest,
+ borderRadius: BorderRadius.circular(20),
+ border: Border.all(color: AppColors.borderSoft),
+ boxShadow: AppShadows.soft,
),
- child: Row(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
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),
- ),
- ],
+ Text(
+ '登录',
+ style: AppTypography.headlineMd.copyWith(
+ color: AppColors.onSurface,
),
),
- 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('请输入手机号码'),
+ const SizedBox(height: 4),
+ const Text(
+ '使用注册手机号和密码登录',
+ style: TextStyle(fontSize: 13, color: AppColors.textMuted),
+ ),
+ const SizedBox(height: 20),
+ _buildLabel('手机号'),
+ const SizedBox(height: 8),
+ TextField(
+ controller: _phone,
+ keyboardType: TextInputType.phone,
+ textInputAction: TextInputAction.next,
+ maxLength: 11,
+ style: const TextStyle(fontSize: 16, color: AppColors.onSurface),
+ cursorColor: AppColors.primary,
+ decoration: _fieldDecoration(
+ hint: '请输入手机号',
+ icon: Icons.phone_iphone_rounded,
),
),
+ const SizedBox(height: 16),
+ _buildLabel('密码'),
+ const SizedBox(height: 8),
+ TextField(
+ controller: _password,
+ obscureText: _obscure,
+ textInputAction: TextInputAction.done,
+ onSubmitted: (_) => _handleLogin(),
+ style: const TextStyle(fontSize: 16, color: AppColors.onSurface),
+ cursorColor: AppColors.primary,
+ decoration: _fieldDecoration(
+ hint: '请输入密码(至少 6 位)',
+ icon: Icons.lock_outline_rounded,
+ suffix: IconButton(
+ onPressed: () => setState(() => _obscure = !_obscure),
+ tooltip: _obscure ? '显示密码' : '隐藏密码',
+ icon: Icon(
+ _obscure
+ ? Icons.visibility_off_outlined
+ : Icons.visibility_outlined,
+ size: 20,
+ color: AppColors.textMuted,
+ ),
+ ),
+ ),
+ ),
+ if (_error.isNotEmpty) ...[
+ const SizedBox(height: 14),
+ _buildError(),
+ ],
+ const SizedBox(height: 20),
+ XPrimaryButton(
+ label: '登录',
+ onPressed: _handleLogin,
+ loading: _loading,
+ height: 52,
+ radius: AppRadius.lg,
+ shadows: AppShadows.primaryButton,
+ ),
+ const SizedBox(height: 16),
+ _buildRegisterEntry(),
],
),
);
}
- /// 密码行:源为「验证码 + 获取验证码」,后端改为密码,右侧按钮换成明文切换。
- 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 _buildLabel(String text) => Text(
+ text,
+ style: AppTypography.bodySm.copyWith(color: AppColors.onSurfaceVariant),
);
- 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,
- ),
- ),
- ],
- ),
+ InputDecoration _fieldDecoration({
+ required String hint,
+ required IconData icon,
+ Widget? suffix,
+ }) {
+ final border = OutlineInputBorder(
+ borderRadius: BorderRadius.circular(AppRadius.lg),
+ borderSide: BorderSide.none,
+ );
+ return InputDecoration(
+ hintText: hint,
+ hintStyle: const TextStyle(fontSize: 15, color: AppColors.textPlaceholder),
+ prefixIcon: Icon(icon, size: 20, color: AppColors.textMuted),
+ prefixIconConstraints: const BoxConstraints(minWidth: 46, minHeight: 46),
+ suffixIcon: suffix,
+ filled: true,
+ fillColor: AppColors.surfaceContainerLow,
+ counterText: '',
+ contentPadding: const EdgeInsets.symmetric(vertical: 16),
+ border: border,
+ enabledBorder: border,
+ focusedBorder: border.copyWith(
+ borderSide: const BorderSide(color: AppColors.primary, width: 1.4),
),
);
}
- 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 _buildError() {
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
+ decoration: BoxDecoration(
+ color: AppColors.errorContainer,
+ borderRadius: BorderRadius.circular(AppRadius.regular + 2),
),
- );
- }
-
- 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 Icon(
+ Icons.error_outline_rounded,
+ size: 16,
+ color: AppColors.error,
),
- const SizedBox(width: 12),
+ const SizedBox(width: 8),
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 在登录时获取我的相关信息。'),
- ],
+ child: Text(
+ _error,
+ style: const TextStyle(
+ fontSize: 13,
+ height: 1.4,
+ color: AppColors.onErrorContainer,
),
),
),
@@ -484,39 +348,59 @@ class _LoginPageState extends ConsumerState {
);
}
+ Widget _buildRegisterEntry() {
+ return Center(
+ child: GestureDetector(
+ onTap: () => context.push('/register'),
+ behavior: HitTestBehavior.opaque,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
+ child: Text.rich(
+ TextSpan(
+ style: const TextStyle(fontSize: 14, color: AppColors.textMuted),
+ children: const [
+ TextSpan(text: '还没有账号?'),
+ TextSpan(
+ text: '立即注册',
+ style: TextStyle(
+ fontWeight: FontWeight.w600,
+ color: AppColors.primary,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+
+ /// 极简版不再放协议勾选框:登录即视为同意,文案置底。
+ Widget _buildAgreementHint() {
+ return Text.rich(
+ TextSpan(
+ style: const TextStyle(
+ fontSize: 12,
+ height: 1.6,
+ color: AppColors.textMuted,
+ ),
+ children: [
+ const TextSpan(text: '登录即表示你已阅读并同意'),
+ _link('《服务协议》'),
+ const TextSpan(text: '与'),
+ _link('《隐私政策》'),
+ ],
+ ),
+ textAlign: TextAlign.center,
+ );
+ }
+
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 与端口,
@@ -618,7 +502,7 @@ class _ServerSettingsSheetState extends ConsumerState<_ServerSettingsSheet> {
_buildField(
label: '服务端 IP',
controller: _host,
- hintText: '如 192.168.3.29',
+ hintText: '如 192.168.21.184',
keyboardType: TextInputType.url,
),
const SizedBox(height: 16),
diff --git a/lib/features/order/data/order_models.dart b/lib/features/order/data/order_models.dart
index 96129d2..44b2183 100644
--- a/lib/features/order/data/order_models.dart
+++ b/lib/features/order/data/order_models.dart
@@ -66,6 +66,34 @@ class Order {
/// 是否处于「现场勘察中(1)」,仅该状态可维护勘察明细。
bool get surveyEditable => source == 'survey' && status == 1;
+ /// 是否已完成:报修(2)、安装/维保(1)、勘察已归档(3)。
+ bool get isCompleted => switch (source) {
+ 'repair' => status == 2,
+ 'install' || 'maintenance' => status == 1,
+ 'survey' => status == 3,
+ _ => false,
+ };
+
+ /// 当前状态文案,用于「进行中 / 已完成」列表的状态标签。
+ String get statusText => switch (source) {
+ 'survey' => switch (status) {
+ 0 => '待指派',
+ 1 => '现场勘察中',
+ 2 => '待技术复核',
+ 3 => '已归档',
+ 5 => '已取消',
+ _ => '进行中',
+ },
+ 'repair' => switch (status) {
+ 0 => '待处理',
+ 1 => '处理中',
+ 2 => '已完成',
+ _ => '进行中',
+ },
+ 'install' || 'maintenance' => status == 1 ? '已完成' : '待上门',
+ _ => isCompleted ? '已完成' : '进行中',
+ };
+
/// 来源中文名,用于收益明细副标题等场景。
String get sourceName => switch (source) {
'repair' => '家庭电路维修',
diff --git a/lib/features/order/presentation/order_detail_page.dart b/lib/features/order/presentation/order_detail_page.dart
index d2eca75..02cbf34 100644
--- a/lib/features/order/presentation/order_detail_page.dart
+++ b/lib/features/order/presentation/order_detail_page.dart
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
+import 'package:url_launcher/url_launcher.dart';
import '../../../core/network/api_response.dart';
import '../../../core/providers.dart';
@@ -33,9 +34,14 @@ class OrderDetailPage extends ConsumerWidget {
final String source;
final int id;
+ /// 地图占位暂时隐藏(代码保留,后续接入真实地图时改回 true)。
+ static const _showMap = false;
+
@override
Widget build(BuildContext context, WidgetRef ref) {
final detail = ref.watch(orderDetailProvider((source: source, id: id)));
+ final order = detail.value;
+ final showBottomAction = order != null && !order.isCompleted;
return Scaffold(
body: XPageShell(
@@ -70,15 +76,16 @@ class OrderDetailPage extends ConsumerWidget {
),
},
),
- // 底部固定操作栏,对应源 `.bottom-action`
- Positioned(
- left: 0,
- right: 0,
- bottom: 0,
- child: _BottomAction(
- onStart: () => showAppToast('已开始出发', success: true),
+ // 已完成订单不再展示出发操作
+ if (showBottomAction)
+ Positioned(
+ left: 0,
+ right: 0,
+ bottom: 0,
+ child: _BottomAction(
+ onStart: () => showAppToast('已开始出发', success: true),
+ ),
),
- ),
],
),
),
@@ -87,11 +94,11 @@ class OrderDetailPage extends ConsumerWidget {
Widget _buildContent(BuildContext context, Order order) {
return SingleChildScrollView(
- padding: const EdgeInsets.only(bottom: 120),
+ padding: EdgeInsets.only(bottom: order.isCompleted ? 24 : 120),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
- _MapSection(address: order.address),
+ if (_showMap) _MapSection(address: order.address),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
@@ -392,7 +399,7 @@ class _CustomerCard extends StatelessWidget {
),
const SizedBox(width: 12),
GestureDetector(
- onTap: () => showAppToast('拨打电话'),
+ onTap: () => _callPhone(order.customerPhone),
behavior: HitTestBehavior.opaque,
child: Container(
width: 40,
@@ -744,6 +751,21 @@ class _DotPatternPainter extends CustomPainter {
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
+/// 调起系统拨号盘。
+Future _callPhone(String raw) async {
+ final phone = raw.replaceAll(RegExp(r'[^0-9+]'), '');
+ if (phone.isEmpty) {
+ showAppToast('该客户暂无联系方式');
+ return;
+ }
+ try {
+ final ok = await launchUrl(Uri(scheme: 'tel', path: phone));
+ if (!ok) showAppToast('无法调起拨号');
+ } catch (_) {
+ showAppToast('无法调起拨号');
+ }
+}
+
/// 打开单张照片大图预览。
void _previewPhoto(BuildContext context, String url) {
showDialog(
diff --git a/lib/features/order/presentation/order_tab.dart b/lib/features/order/presentation/order_tab.dart
index 48d5e13..ddfd7c6 100644
--- a/lib/features/order/presentation/order_tab.dart
+++ b/lib/features/order/presentation/order_tab.dart
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
+import 'package:url_launcher/url_launcher.dart';
import '../../../core/network/api_response.dart';
import '../../../core/providers.dart';
@@ -20,10 +21,10 @@ import '../data/order_models.dart';
String _money(num v) =>
v == v.roundToDouble() ? v.toStringAsFixed(0) : formatMoney(v);
-/// 订单 Tab(待接订单),对应源 `pages/order/index.vue`。
+/// 订单 Tab:顶部三档切换「进行中 / 待接单 / 已完成」。
///
-/// 顶栏与地图区固定,只有下方订单列表滚动;沿用源 `.orders-section`
-/// 24px 顶部圆角「上移压住地图」的观感(把地图高度相应减 24,视觉等价)。
+/// 「进行中」「已完成」来自 `myOrders`,待接单来自订单池;地图区域暂时隐藏,
+/// `_showMap` 置 false 保留占位实现,后续接入真实地图时改回 true 即可。
class OrderTab extends ConsumerStatefulWidget {
const OrderTab({super.key});
@@ -32,10 +33,34 @@ class OrderTab extends ConsumerStatefulWidget {
}
class _OrderTabState extends ConsumerState {
+ /// 地图占位暂时隐藏(代码保留)。
+ static const _showMap = false;
+
+ static const _segments = ['进行中', '待接单', '已完成'];
+
+ /// 待接单的类型筛选,null 表示全部。
+ static const _typeFilters = <(String?, String)>[
+ (null, '全部'),
+ ('repair', '报修'),
+ ('install', '安装'),
+ ('maintenance', '维保'),
+ ('survey', '勘察'),
+ ];
+
+ /// 0 进行中 / 1 待接单 / 2 已完成。
+ int _segment = 0;
+ String? _typeFilter;
+
/// 本会话内已接单的订单 id(接单后列表会刷新移除,这里用于渲染「已接单」态)。
final _grabbed = {};
String? _grabbing;
+ /// 只订阅当前分段的列表,切换时旧的 autoDispose 实例自动释放。
+ AsyncValue> _watchActive() => switch (_segment) {
+ 1 => ref.watch(orderPoolProvider),
+ _ => ref.watch(myOrdersProvider(_segment == 0 ? 'pending' : 'completed')),
+ };
+
Future _handleGrab(Order order) async {
if (_grabbing != null || _grabbed.contains(order.id)) return;
setState(() => _grabbing = order.id);
@@ -45,9 +70,14 @@ class _OrderTabState extends ConsumerState {
.grab(order.source, order.intId);
if (!mounted) return;
if (res.isOk) {
- setState(() => _grabbed.add(order.id));
+ setState(() {
+ _grabbed.add(order.id);
+ // 接单成功后订单转入「进行中」,顺手切到该分段,避免用户还要自己找
+ _segment = 0;
+ });
showAppToast('接单成功', success: true);
ref.invalidate(orderPoolProvider);
+ ref.invalidate(myOrdersProvider);
} else {
showAppToast(res.message.isEmpty ? '接单失败' : res.message);
}
@@ -58,9 +88,41 @@ class _OrderTabState extends ConsumerState {
}
}
+ Future _refresh() async {
+ ref.invalidate(orderPoolProvider);
+ ref.invalidate(myOrdersProvider);
+ try {
+ switch (_segment) {
+ case 1:
+ await ref.read(orderPoolProvider.future);
+ case 0:
+ await ref.read(myOrdersProvider('pending').future);
+ default:
+ await ref.read(myOrdersProvider('completed').future);
+ }
+ } catch (_) {
+ // 失败由列表的 hasError 分支展示,这里只负责结束下拉动画
+ }
+ }
+
+ /// 联系客户:调起系统拨号盘。
+ Future _callCustomer(Order order) async {
+ final phone = order.customerPhone.replaceAll(RegExp(r'[^0-9+]'), '');
+ if (phone.isEmpty) {
+ showAppToast('该客户暂无联系方式');
+ return;
+ }
+ try {
+ final ok = await launchUrl(Uri(scheme: 'tel', path: phone));
+ if (!ok && mounted) showAppToast('无法调起拨号');
+ } catch (_) {
+ if (mounted) showAppToast('无法调起拨号');
+ }
+ }
+
@override
Widget build(BuildContext context) {
- final pool = ref.watch(orderPoolProvider);
+ final active = _watchActive();
final unread = ref.watch(noticeUnreadCountProvider);
return XPageShell(
@@ -72,93 +134,176 @@ class _OrderTabState extends ConsumerState {
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,
+ child: Column(
+ children: [
+ _buildSegments(),
+ Expanded(
+ child: RefreshIndicator(
+ color: AppColors.primary,
+ onRefresh: _refresh,
+ child: ListView(
+ padding: EdgeInsets.zero,
+ physics: const AlwaysScrollableScrollPhysics(),
children: [
- _buildSectionHeader(pool),
- const SizedBox(height: 16),
- ..._buildBody(pool),
+ // 源地图高 320,被下方 24px 圆角区块遮住底部 → 这里直接取 296
+ if (_showMap)
+ const SizedBox(height: 296, child: _MapPlaceholder()),
+ Container(
+ decoration: BoxDecoration(
+ color: AppColors.bgPage,
+ borderRadius: _showMap
+ ? const BorderRadius.vertical(
+ top: Radius.circular(AppRadius.xxl),
+ )
+ : BorderRadius.zero,
+ ),
+ padding: const EdgeInsets.fromLTRB(16, 20, 16, 120),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ _buildSectionHeader(active),
+ if (_segment == 1) ...[
+ const SizedBox(height: 12),
+ _buildTypeFilter(),
+ ],
+ const SizedBox(height: 16),
+ ..._buildBody(active),
+ ],
+ ),
+ ),
],
),
),
+ ),
+ ],
+ ),
+ );
+ }
+
+ /// 顶部三档分段控件。
+ Widget _buildSegments() {
+ return Padding(
+ padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
+ child: Container(
+ padding: const EdgeInsets.all(4),
+ decoration: BoxDecoration(
+ color: AppColors.surfaceContainer,
+ borderRadius: BorderRadius.circular(AppRadius.full),
+ ),
+ child: Row(
+ children: [
+ for (var i = 0; i < _segments.length; i++)
+ Expanded(
+ child: GestureDetector(
+ onTap: () => setState(() => _segment = i),
+ behavior: HitTestBehavior.opaque,
+ child: AnimatedContainer(
+ duration: const Duration(milliseconds: 180),
+ height: 36,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: _segment == i
+ ? AppColors.surfaceContainerLowest
+ : Colors.transparent,
+ borderRadius: BorderRadius.circular(AppRadius.full),
+ boxShadow: _segment == i
+ ? const [
+ BoxShadow(
+ color: Color(0x14000000),
+ blurRadius: 6,
+ offset: Offset(0, 2),
+ ),
+ ]
+ : null,
+ ),
+ child: Text(
+ _segments[i],
+ style: TextStyle(
+ fontSize: 14,
+ fontWeight: _segment == i
+ ? FontWeight.w700
+ : FontWeight.w500,
+ color: _segment == i
+ ? AppColors.onSurface
+ : AppColors.onSurfaceVariant,
+ ),
+ ),
+ ),
+ ),
+ ),
],
),
),
);
}
- Widget _buildSectionHeader(AsyncValue> pool) {
- final count = pool.value?.length;
- return Row(
+ Widget _buildSectionHeader(AsyncValue> active) {
+ final count = active.value?.length;
+ final (title, loadingText, unit) = switch (_segment) {
+ 0 => ('进行中派单', '正在获取进行中的派单…', ' 个进行中'),
+ 1 => ('待接订单', '正在获取附近订单…', ' 个待接订单'),
+ _ => ('已完成订单', '正在获取已完成订单…', ' 个已完成'),
+ };
+ return Column(
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,
- ),
- ),
- ],
+ Text(
+ title,
+ style: AppTypography.navLargeTitle.copyWith(
+ color: AppColors.onSurface,
),
),
- 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),
- ],
- ),
+ const SizedBox(height: 4),
+ Text(
+ count == null ? loadingText : '共 $count$unit',
+ style: AppTypography.bodyMd.copyWith(
+ color: AppColors.onSurfaceVariant,
),
),
],
);
}
- List _buildBody(AsyncValue> pool) {
- if (pool.isLoading && !pool.hasValue) {
+ /// 待接单的类型筛选。
+ Widget _buildTypeFilter() {
+ return SizedBox(
+ height: 34,
+ child: ListView.separated(
+ scrollDirection: Axis.horizontal,
+ itemCount: _typeFilters.length,
+ separatorBuilder: (_, _) => const SizedBox(width: 8),
+ itemBuilder: (context, i) {
+ final (value, label) = _typeFilters[i];
+ final selected = _typeFilter == value;
+ return GestureDetector(
+ onTap: () => setState(() => _typeFilter = value),
+ behavior: HitTestBehavior.opaque,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 14),
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: selected
+ ? AppColors.primary
+ : AppColors.surfaceContainerLow,
+ borderRadius: BorderRadius.circular(AppRadius.full),
+ ),
+ child: Text(
+ label,
+ style: TextStyle(
+ fontSize: 13,
+ fontWeight: FontWeight.w600,
+ color: selected ? Colors.white : AppColors.onSurfaceVariant,
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ );
+ }
+
+ List _buildBody(AsyncValue> active) {
+ if (active.isLoading && !active.hasValue) {
return const [
Padding(
padding: EdgeInsets.symmetric(vertical: 60),
@@ -175,31 +320,62 @@ class _OrderTabState extends ConsumerState {
),
];
}
- if (pool.hasError && !pool.hasValue) {
+ if (active.hasError && !active.hasValue) {
return [
- XPlaceholder(text: '订单加载失败\n${pool.error}'),
+ XPlaceholder(text: '订单加载失败\n${active.error}'),
Center(
child: TextButton(
- onPressed: () => ref.invalidate(orderPoolProvider),
+ onPressed: () {
+ ref.invalidate(orderPoolProvider);
+ ref.invalidate(myOrdersProvider);
+ },
child: const Text('点击重试'),
),
),
];
}
- final orders = pool.value ?? const [];
+ final orders = active.value ?? const [];
+ if (_segment == 1) {
+ final filtered = _typeFilter == null
+ ? orders
+ : orders.where((o) => o.source == _typeFilter).toList();
+ if (filtered.isEmpty) {
+ return [
+ XPlaceholder(
+ text: _typeFilter == null ? '附近暂无待接订单' : '该类型暂无待接订单',
+ ),
+ ];
+ }
+ return [
+ for (final order in filtered)
+ 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}'),
+ ),
+ ),
+ ];
+ }
+
if (orders.isEmpty) {
- return const [XPlaceholder(text: '附近暂无待接订单')];
+ return [
+ XPlaceholder(
+ text: _segment == 0 ? '暂无进行中的派单' : '暂无已完成订单',
+ ),
+ ];
}
return [
for (final order in orders)
Padding(
padding: const EdgeInsets.only(bottom: 16),
- child: _OrderCard(
+ child: _MyOrderCard(
order: order,
- grabbing: _grabbing == order.id,
- grabbed: _grabbed.contains(order.id),
- onGrab: () => _handleGrab(order),
+ onCall: () => _callCustomer(order),
onTap: () => context.push('/order/${order.source}/${order.intId}'),
),
),
@@ -410,6 +586,206 @@ class _OrderCard extends StatelessWidget {
);
}
+/// 进行中 / 已完成订单卡片:状态标签 + 联系客户 + 查看详情。
+class _MyOrderCard extends StatelessWidget {
+ const _MyOrderCard({
+ required this.order,
+ required this.onCall,
+ required this.onTap,
+ });
+
+ final Order order;
+ final VoidCallback onCall;
+ 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),
+ ],
+ ),
+ ),
+ const SizedBox(width: 8),
+ _statusChip(),
+ ],
+ ),
+ const SizedBox(height: 12),
+ Text(
+ order.title,
+ style: AppTypography.headlineMd.copyWith(
+ color: AppColors.onSurface,
+ ),
+ ),
+ const SizedBox(height: 10),
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Padding(
+ padding: EdgeInsets.only(top: 2),
+ child: XIcon(
+ 'location',
+ size: 16,
+ color: AppColors.onSurfaceVariant,
+ ),
+ ),
+ const SizedBox(width: 6),
+ Expanded(
+ child: Text(
+ order.address.isEmpty ? '地址待确认' : order.address,
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: AppTypography.bodyMd.copyWith(
+ color: AppColors.onSurfaceVariant,
+ ),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 6),
+ Row(
+ children: [
+ 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(width: 12),
+ Text(
+ '¥${_money(order.amount)}',
+ style: AppTypography.bodyLg.copyWith(
+ fontWeight: FontWeight.w700,
+ color: AppColors.primary,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 16),
+ Row(
+ children: [
+ Expanded(
+ child: _actionButton(
+ label: '联系客户',
+ icon: 'call',
+ filled: false,
+ onTap: onCall,
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: _actionButton(
+ label: '查看详情',
+ icon: 'forward',
+ filled: true,
+ onTap: onTap,
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _statusChip() {
+ final completed = order.isCompleted;
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
+ decoration: BoxDecoration(
+ color: completed ? AppColors.surfaceContainer : AppColors.accentSubtle,
+ borderRadius: BorderRadius.circular(AppRadius.full),
+ ),
+ child: Text(
+ order.statusText,
+ style: TextStyle(
+ fontSize: 12,
+ fontWeight: FontWeight.w600,
+ color: completed ? AppColors.onSurfaceVariant : AppColors.primary,
+ ),
+ ),
+ );
+ }
+
+ Widget _actionButton({
+ required String label,
+ required String icon,
+ required bool filled,
+ required VoidCallback onTap,
+ }) {
+ final foreground = filled ? Colors.white : AppColors.onSurface;
+ return GestureDetector(
+ onTap: onTap,
+ behavior: HitTestBehavior.opaque,
+ child: Container(
+ height: 46,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ color: filled ? AppColors.primary : AppColors.surfaceContainerLow,
+ borderRadius: BorderRadius.circular(AppRadius.lg),
+ border: filled
+ ? null
+ : Border.all(color: const Color(0x80C3C5D9)),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ XIcon(icon, size: 18, color: foreground),
+ const SizedBox(width: 8),
+ Text(
+ label,
+ style: TextStyle(
+ fontSize: 15,
+ fontWeight: FontWeight.w600,
+ color: foreground,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
/// 接单中的旋转刷新图标。
class _SpinningRefresh extends StatefulWidget {
const _SpinningRefresh();
diff --git a/pubspec.lock b/pubspec.lock
index fa9f166..f858dfb 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -738,6 +738,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
+ url_launcher:
+ dependency: "direct main"
+ description:
+ name: url_launcher
+ sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.3.2"
+ url_launcher_android:
+ dependency: transitive
+ description:
+ name: url_launcher_android
+ sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.3.29"
+ url_launcher_ios:
+ dependency: transitive
+ description:
+ name: url_launcher_ios
+ sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.4.1"
+ url_launcher_linux:
+ dependency: transitive
+ description:
+ name: url_launcher_linux
+ sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.2.2"
+ url_launcher_macos:
+ dependency: transitive
+ description:
+ name: url_launcher_macos
+ sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.2.5"
+ url_launcher_platform_interface:
+ dependency: transitive
+ description:
+ name: url_launcher_platform_interface
+ sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.3.2"
+ url_launcher_web:
+ dependency: transitive
+ description:
+ name: url_launcher_web
+ sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.4.3"
+ url_launcher_windows:
+ dependency: transitive
+ description:
+ name: url_launcher_windows
+ sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.1.5"
uuid:
dependency: transitive
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index f3f7a01..f4e4189 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -20,6 +20,7 @@ dependencies:
shared_preferences: ^2.5.5
flutter_svg: ^2.3.0
image_picker: ^1.2.3
+ url_launcher: ^6.3.2
dev_dependencies:
flutter_test: