feat: 新增勘察明细管理与修改密码功能
This commit is contained in:
1 parent
56830e94ec
commit
774403785c
12 files changed
+1552
-17
No files matched your search
@@ -4,6 +4,12 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>用于拍摄勘察现场照片</string>
|
||||||
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
|
<string>用于从相册选择勘察现场照片</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>用于拍摄勘察现场照片</string>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
|
|||||||
+5
-2
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
|
|
||||||
import '../core/app_keys.dart';
|
import '../core/app_keys.dart';
|
||||||
import '../features/auth/application/session_controller.dart';
|
import '../features/auth/application/session_controller.dart';
|
||||||
|
import '../features/auth/presentation/change_password_page.dart';
|
||||||
import '../features/auth/presentation/login_page.dart';
|
import '../features/auth/presentation/login_page.dart';
|
||||||
import '../features/auth/presentation/register_page.dart';
|
import '../features/auth/presentation/register_page.dart';
|
||||||
import '../features/notice/presentation/notice_center_page.dart';
|
import '../features/notice/presentation/notice_center_page.dart';
|
||||||
@@ -38,9 +39,11 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(path: '/login', builder: (context, state) => const LoginPage()),
|
GoRoute(path: '/login', builder: (context, state) => const LoginPage()),
|
||||||
|
GoRoute(path: '/register', builder: (context, state) => const RegisterPage()),
|
||||||
|
// 修改登录密码:「我的」页入口
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/register',
|
path: '/change-password',
|
||||||
builder: (context, state) => const RegisterPage(),
|
builder: (context, state) => const ChangePasswordPage(),
|
||||||
),
|
),
|
||||||
GoRoute(path: '/tabs', builder: (context, state) => const TabsPage()),
|
GoRoute(path: '/tabs', builder: (context, state) => const TabsPage()),
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,31 @@ import 'package:dio/dio.dart';
|
|||||||
import '../storage/token_storage.dart';
|
import '../storage/token_storage.dart';
|
||||||
import 'api_response.dart';
|
import 'api_response.dart';
|
||||||
|
|
||||||
|
/// 图片上传结果,与后端 `POST /api/upload/image` 的 `data` 对齐。
|
||||||
|
class UploadResult {
|
||||||
|
const UploadResult({
|
||||||
|
required this.key,
|
||||||
|
required this.url,
|
||||||
|
required this.name,
|
||||||
|
required this.size,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 对象存储 key,持久化到业务表。
|
||||||
|
final String key;
|
||||||
|
|
||||||
|
/// 预签名访问 URL,用于即时预览。
|
||||||
|
final String url;
|
||||||
|
final String name;
|
||||||
|
final int size;
|
||||||
|
|
||||||
|
factory UploadResult.fromJson(Map<String, dynamic> json) => UploadResult(
|
||||||
|
key: json['key'] as String? ?? '',
|
||||||
|
url: json['url'] as String? ?? '',
|
||||||
|
name: json['name'] as String? ?? '',
|
||||||
|
size: (json['size'] as num?)?.toInt() ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 统一请求封装,等价于 uni-app 版 `utils/request.ts`:
|
/// 统一请求封装,等价于 uni-app 版 `utils/request.ts`:
|
||||||
/// - 自动注入 `Authorization: Bearer <token>`
|
/// - 自动注入 `Authorization: Bearer <token>`
|
||||||
/// - 解析 `{ code, message, data }`,非 200 也照常返回给调用方判断
|
/// - 解析 `{ code, message, data }`,非 200 也照常返回给调用方判断
|
||||||
@@ -64,6 +89,36 @@ class ApiClient {
|
|||||||
bool needAuth = true,
|
bool needAuth = true,
|
||||||
}) => _send<T>('PUT', url, body: body, parse: parse, needAuth: needAuth);
|
}) => _send<T>('PUT', url, body: body, parse: parse, needAuth: needAuth);
|
||||||
|
|
||||||
|
/// 上传现场照片到对象存储(multipart/form-data,字段名 `file`)。
|
||||||
|
/// 走电工通道 `POST /api/worker-app/upload/image`(电工 token 无法访问管理端 `/api/upload`)。
|
||||||
|
/// 依赖对象存储:失败直接抛异常,由调用方阻断保存并提示用户。
|
||||||
|
Future<UploadResult> uploadImage(String filePath) async {
|
||||||
|
try {
|
||||||
|
final form = FormData.fromMap({
|
||||||
|
'file': await MultipartFile.fromFile(filePath),
|
||||||
|
});
|
||||||
|
final res = await _dio.post<dynamic>(
|
||||||
|
'/worker-app/upload/image',
|
||||||
|
data: form,
|
||||||
|
options: Options(headers: _authHeader()),
|
||||||
|
);
|
||||||
|
final json = _asMap(res.data);
|
||||||
|
final code = (json['code'] as num?)?.toInt();
|
||||||
|
final data = json['data'];
|
||||||
|
if (code == 200 && data is Map) {
|
||||||
|
return UploadResult.fromJson(data.cast<String, dynamic>());
|
||||||
|
}
|
||||||
|
throw ApiException(
|
||||||
|
(json['message'] as String?) ?? '图片上传失败',
|
||||||
|
code: code,
|
||||||
|
);
|
||||||
|
} on ApiException {
|
||||||
|
rethrow;
|
||||||
|
} catch (_) {
|
||||||
|
throw const ApiException('图片上传失败:网络异常');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void dispose() => _unauthorizedController.close();
|
void dispose() => _unauthorizedController.close();
|
||||||
|
|
||||||
// ---- 内部实现 ----
|
// ---- 内部实现 ----
|
||||||
|
|||||||
@@ -55,4 +55,12 @@ class AuthApi {
|
|||||||
},
|
},
|
||||||
parse: (d) => WorkerInfo.fromJson((d as Map).cast<String, dynamic>()),
|
parse: (d) => WorkerInfo.fromJson((d as Map).cast<String, dynamic>()),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// 修改登录密码。后端 `/worker-app/profile` 收到 `password` 即更新密码哈希。
|
||||||
|
Future<ApiResponse<WorkerInfo>> changePassword(String password) =>
|
||||||
|
_client.put<WorkerInfo>(
|
||||||
|
'/worker-app/profile',
|
||||||
|
body: {'password': password},
|
||||||
|
parse: (d) => WorkerInfo.fromJson((d as Map).cast<String, dynamic>()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
/// 修改登录密码页。
|
||||||
|
///
|
||||||
|
/// 后端 `/worker-app/profile` 传 `password` 即更新密码哈希,
|
||||||
|
/// 因此本页只校验新密码的长度与两次输入一致,不要求原密码。
|
||||||
|
class ChangePasswordPage extends ConsumerStatefulWidget {
|
||||||
|
const ChangePasswordPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ChangePasswordPage> createState() => _ChangePasswordPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChangePasswordPageState extends ConsumerState<ChangePasswordPage> {
|
||||||
|
final _password = TextEditingController();
|
||||||
|
final _confirm = TextEditingController();
|
||||||
|
|
||||||
|
bool _loading = false;
|
||||||
|
String _error = '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_password.addListener(_onChanged);
|
||||||
|
_confirm.addListener(_onChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_password.dispose();
|
||||||
|
_confirm.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onChanged() => setState(() {});
|
||||||
|
|
||||||
|
bool get _canSubmit =>
|
||||||
|
_password.text.length >= 6 && _password.text == _confirm.text;
|
||||||
|
|
||||||
|
Future<void> _handleSubmit() async {
|
||||||
|
if (!_canSubmit) {
|
||||||
|
setState(() => _error = '新密码至少 6 位,且两次输入需一致');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_loading = true;
|
||||||
|
_error = '';
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final res = await ref
|
||||||
|
.read(authApiProvider)
|
||||||
|
.changePassword(_password.text);
|
||||||
|
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: '至少 6 位',
|
||||||
|
controller: _password,
|
||||||
|
required: true,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
_Field(
|
||||||
|
label: '确认新密码',
|
||||||
|
hint: '请再次输入新密码',
|
||||||
|
controller: _confirm,
|
||||||
|
required: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
XPrimaryButton(
|
||||||
|
label: '确认修改',
|
||||||
|
height: 56,
|
||||||
|
radius: AppRadius.xl,
|
||||||
|
loading: _loading,
|
||||||
|
onPressed: _canSubmit ? _handleSubmit : 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(
|
||||||
|
'如忘记密码,可联系平台管理员重置为手机号后 6 位。',
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String hint;
|
||||||
|
final TextEditingController controller;
|
||||||
|
final bool required;
|
||||||
|
|
||||||
|
@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: true,
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -108,6 +108,16 @@ class _NoticeCenterPageState extends ConsumerState<NoticeCenterPage> {
|
|||||||
|
|
||||||
Future<void> _loadMore() => _load();
|
Future<void> _loadMore() => _load();
|
||||||
|
|
||||||
|
/// 点击消息:先标记已读;勘察指派消息(`bizType == "survey_order"`)
|
||||||
|
/// 带 `bizId` 时跳转到对应勘察订单详情,其余保持仅标记已读。
|
||||||
|
Future<void> _onNoticeTap(Notice notice) async {
|
||||||
|
if (!notice.isRead) await _markRead(notice);
|
||||||
|
final bizId = notice.bizId;
|
||||||
|
if (notice.bizType == 'survey_order' && bizId != null && mounted) {
|
||||||
|
context.push('/order/survey/$bizId');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _markRead(Notice notice) async {
|
Future<void> _markRead(Notice notice) async {
|
||||||
if (notice.isRead) return;
|
if (notice.isRead) return;
|
||||||
try {
|
try {
|
||||||
@@ -231,7 +241,7 @@ class _NoticeCenterPageState extends ConsumerState<NoticeCenterPage> {
|
|||||||
final notice = _items[index];
|
final notice = _items[index];
|
||||||
return _NoticeTile(
|
return _NoticeTile(
|
||||||
notice: notice,
|
notice: notice,
|
||||||
onTap: notice.isRead ? null : () => _markRead(notice),
|
onTap: () => _onNoticeTap(notice),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -281,7 +291,7 @@ class _NoticeCenterPageState extends ConsumerState<NoticeCenterPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 单条消息卡片:未读有蓝底高亮 + 红点,点击标记已读。
|
/// 单条消息卡片:未读有蓝底高亮 + 红点,点击标记已读并按业务类型跳转。
|
||||||
class _NoticeTile extends StatelessWidget {
|
class _NoticeTile extends StatelessWidget {
|
||||||
const _NoticeTile({required this.notice, this.onTap});
|
const _NoticeTile({required this.notice, this.onTap});
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,19 @@ class OrderApi {
|
|||||||
Future<ApiResponse<void>> grab(String source, int id) =>
|
Future<ApiResponse<void>> grab(String source, int id) =>
|
||||||
_client.post<void>('/worker-app/orders/$source/$id/grab');
|
_client.post<void>('/worker-app/orders/$source/$id/grab');
|
||||||
|
|
||||||
|
/// 保存勘察明细(全量替换)。仅本人已接单且状态为「现场勘察中」的勘察单可写,
|
||||||
|
/// 成功返回服务端最新明细(含预签名 `photoUrls`)。
|
||||||
|
Future<ApiResponse<List<SurveyDetail>>> saveSurveyDetails(
|
||||||
|
int id,
|
||||||
|
List<SurveyDetail> details,
|
||||||
|
) => _client.put<List<SurveyDetail>>(
|
||||||
|
'/worker-app/orders/survey/$id/details',
|
||||||
|
body: {'details': details.map((d) => d.toPayload()).toList()},
|
||||||
|
parse: (d) => (d as List? ?? const [])
|
||||||
|
.map((e) => SurveyDetail.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
/// 完成订单。非本人订单后端返回 403。
|
/// 完成订单。非本人订单后端返回 403。
|
||||||
Future<ApiResponse<void>> complete(String source, int id) =>
|
Future<ApiResponse<void>> complete(String source, int id) =>
|
||||||
_client.put<void>('/worker-app/orders/$source/$id/complete');
|
_client.put<void>('/worker-app/orders/$source/$id/complete');
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ class Order {
|
|||||||
required this.laborFee,
|
required this.laborFee,
|
||||||
required this.materialFee,
|
required this.materialFee,
|
||||||
required this.estimatedDuration,
|
required this.estimatedDuration,
|
||||||
|
this.details = const [],
|
||||||
|
this.topology,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// `repair-12`
|
/// `repair-12`
|
||||||
@@ -52,6 +54,18 @@ class Order {
|
|||||||
final double materialFee;
|
final double materialFee;
|
||||||
final String estimatedDuration;
|
final String estimatedDuration;
|
||||||
|
|
||||||
|
/// 勘察明细(仅 `source == "survey"` 的详情接口返回)。
|
||||||
|
final List<SurveyDetail> details;
|
||||||
|
|
||||||
|
/// 客户空间拓扑(仅 `source == "survey"` 的详情接口返回)。
|
||||||
|
final CustomerTopology? topology;
|
||||||
|
|
||||||
|
/// 是否为现场勘察单。
|
||||||
|
bool get isSurvey => source == 'survey';
|
||||||
|
|
||||||
|
/// 是否处于「现场勘察中(1)」,仅该状态可维护勘察明细。
|
||||||
|
bool get surveyEditable => source == 'survey' && status == 1;
|
||||||
|
|
||||||
/// 来源中文名,用于收益明细副标题等场景。
|
/// 来源中文名,用于收益明细副标题等场景。
|
||||||
String get sourceName => switch (source) {
|
String get sourceName => switch (source) {
|
||||||
'repair' => '家庭电路维修',
|
'repair' => '家庭电路维修',
|
||||||
@@ -109,5 +123,164 @@ class Order {
|
|||||||
laborFee: toDoubleOr(json['laborFee'], 0),
|
laborFee: toDoubleOr(json['laborFee'], 0),
|
||||||
materialFee: toDoubleOr(json['materialFee'], 0),
|
materialFee: toDoubleOr(json['materialFee'], 0),
|
||||||
estimatedDuration: json['estimatedDuration'] as String? ?? '',
|
estimatedDuration: json['estimatedDuration'] as String? ?? '',
|
||||||
|
details: (json['details'] as List? ?? const [])
|
||||||
|
.map((e) => SurveyDetail.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(),
|
||||||
|
topology: json['topology'] is Map
|
||||||
|
? CustomerTopology.fromJson(
|
||||||
|
(json['topology'] as Map).cast<String, dynamic>(),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 勘察明细:客户空间拓扑中的「建筑 + 楼层 + 房间(选填) + 现场照片 + 备注」。
|
||||||
|
class SurveyDetail {
|
||||||
|
const SurveyDetail({
|
||||||
|
this.id = 0,
|
||||||
|
required this.buildingId,
|
||||||
|
required this.buildingName,
|
||||||
|
required this.floorNo,
|
||||||
|
this.roomId,
|
||||||
|
this.roomName = '',
|
||||||
|
this.photos = const [],
|
||||||
|
this.photoUrls = const [],
|
||||||
|
this.remark = '',
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 后端主键,新增行传 0(服务端忽略)。
|
||||||
|
final int id;
|
||||||
|
final int buildingId;
|
||||||
|
final String buildingName;
|
||||||
|
|
||||||
|
/// 楼层:地上 `"1"`、地下 `"-1"`。
|
||||||
|
final String floorNo;
|
||||||
|
|
||||||
|
final int? roomId;
|
||||||
|
final String roomName;
|
||||||
|
|
||||||
|
/// 对象存储 key,提交时传数组。
|
||||||
|
final List<String> photos;
|
||||||
|
|
||||||
|
/// 预签名访问 URL,仅用于预览。
|
||||||
|
final List<String> photoUrls;
|
||||||
|
final String remark;
|
||||||
|
|
||||||
|
/// 楼层展示文案:正数 `N层`、负数 `负N层`,无值返回空串。
|
||||||
|
String get floorLabel {
|
||||||
|
final n = int.tryParse(floorNo);
|
||||||
|
if (n == null || n == 0) return '';
|
||||||
|
return n > 0 ? '$n层' : '负${-n}层';
|
||||||
|
}
|
||||||
|
|
||||||
|
factory SurveyDetail.fromJson(Map<String, dynamic> json) => SurveyDetail(
|
||||||
|
id: toIntOr(json['id'], 0),
|
||||||
|
buildingId: toIntOr(json['buildingId'], 0),
|
||||||
|
buildingName: json['buildingName'] as String? ?? '',
|
||||||
|
floorNo: json['floorNo']?.toString() ?? '',
|
||||||
|
roomId: toIntOrNull(json['roomId']),
|
||||||
|
roomName: json['roomName'] as String? ?? '',
|
||||||
|
photos:
|
||||||
|
(json['photos'] as List?)?.map((e) => e.toString()).toList() ??
|
||||||
|
const [],
|
||||||
|
photoUrls:
|
||||||
|
(json['photoUrls'] as List?)?.map((e) => e.toString()).toList() ??
|
||||||
|
const [],
|
||||||
|
remark: json['remark'] as String? ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 提交给后端的字段(全量替换数组的元素)。
|
||||||
|
Map<String, dynamic> toPayload() => {
|
||||||
|
'buildingId': buildingId,
|
||||||
|
'buildingName': buildingName,
|
||||||
|
'floorNo': floorNo,
|
||||||
|
'roomId': roomId,
|
||||||
|
'roomName': roomName,
|
||||||
|
'photos': photos,
|
||||||
|
'remark': remark,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拓扑建筑:`floorCount` 地上层数、`floorDownCount` 地下层数。
|
||||||
|
class TopologyBuilding {
|
||||||
|
const TopologyBuilding({
|
||||||
|
required this.id,
|
||||||
|
required this.buildingName,
|
||||||
|
required this.floorCount,
|
||||||
|
required this.floorDownCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final String buildingName;
|
||||||
|
final int floorCount;
|
||||||
|
final int floorDownCount;
|
||||||
|
|
||||||
|
factory TopologyBuilding.fromJson(Map<String, dynamic> json) =>
|
||||||
|
TopologyBuilding(
|
||||||
|
id: toIntOr(json['id'], 0),
|
||||||
|
buildingName: json['buildingName'] as String? ?? '',
|
||||||
|
floorCount: toIntOr(json['floorCount'], 0),
|
||||||
|
floorDownCount: toIntOr(json['floorDownCount'], 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 生成楼层选项:value 地上 `"1"`/地下 `"-1"`,label `"1层"`/`"负1层"`。
|
||||||
|
List<({String value, String label})> get floorOptions => [
|
||||||
|
for (var i = 1; i <= floorCount; i++) (value: '$i', label: '$i层'),
|
||||||
|
for (var i = 1; i <= floorDownCount; i++) (value: '-$i', label: '负$i层'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拓扑房间:按 `buildingId` + `floorNo` 归属。
|
||||||
|
class TopologyRoom {
|
||||||
|
const TopologyRoom({
|
||||||
|
required this.id,
|
||||||
|
required this.roomName,
|
||||||
|
required this.buildingId,
|
||||||
|
required this.floorNo,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final String roomName;
|
||||||
|
final int buildingId;
|
||||||
|
final String floorNo;
|
||||||
|
|
||||||
|
factory TopologyRoom.fromJson(Map<String, dynamic> json) => TopologyRoom(
|
||||||
|
id: toIntOr(json['id'], 0),
|
||||||
|
roomName: json['roomName'] as String? ?? '',
|
||||||
|
buildingId: toIntOr(json['buildingId'], 0),
|
||||||
|
floorNo: json['floorNo']?.toString() ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 客户空间拓扑:建筑 + 房间。
|
||||||
|
class CustomerTopology {
|
||||||
|
const CustomerTopology({this.buildings = const [], this.rooms = const []});
|
||||||
|
|
||||||
|
final List<TopologyBuilding> buildings;
|
||||||
|
final List<TopologyRoom> rooms;
|
||||||
|
|
||||||
|
factory CustomerTopology.fromJson(Map<String, dynamic> json) =>
|
||||||
|
CustomerTopology(
|
||||||
|
buildings: (json['buildings'] as List? ?? const [])
|
||||||
|
.map(
|
||||||
|
(e) => TopologyBuilding.fromJson((e as Map).cast<String, dynamic>()),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
rooms: (json['rooms'] as List? ?? const [])
|
||||||
|
.map((e) => TopologyRoom.fromJson((e as Map).cast<String, dynamic>()))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 按建筑 + 楼层过滤房间,用于明细编辑时的房间下拉。
|
||||||
|
List<TopologyRoom> roomsOf(int buildingId, String floorNo) => rooms
|
||||||
|
.where((r) => r.buildingId == buildingId && r.floorNo == floorNo)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
/// 按 id 查建筑。
|
||||||
|
TopologyBuilding? buildingOf(int id) {
|
||||||
|
for (final b in buildings) {
|
||||||
|
if (b.id == id) return b;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
|
||||||
|
import '../../../core/network/api_response.dart';
|
||||||
|
import '../../../core/providers.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
import '../../../core/theme/app_typography.dart';
|
import '../../../core/theme/app_typography.dart';
|
||||||
@@ -98,6 +101,9 @@ class OrderDetailPage extends ConsumerWidget {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_CustomerCard(order: order),
|
_CustomerCard(order: order),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
if (order.isSurvey)
|
||||||
|
_SurveyDetailsSection(order: order)
|
||||||
|
else
|
||||||
_PhotosSection(photos: order.photos),
|
_PhotosSection(photos: order.photos),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_FinancialCard(order: order),
|
_FinancialCard(order: order),
|
||||||
@@ -737,3 +743,939 @@ class _DotPatternPainter extends CustomPainter {
|
|||||||
@override
|
@override
|
||||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 打开单张照片大图预览。
|
||||||
|
void _previewPhoto(BuildContext context, String url) {
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierColor: Colors.black,
|
||||||
|
builder: (_) => Dialog(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
insetPadding: EdgeInsets.zero,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => Navigator.of(context).pop(),
|
||||||
|
child: Center(child: Image.network(url, fit: BoxFit.contain)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 勘察明细区块:展示客户空间拓扑与明细列表;「现场勘察中」时可增删改并保存。
|
||||||
|
class _SurveyDetailsSection extends ConsumerStatefulWidget {
|
||||||
|
const _SurveyDetailsSection({required this.order});
|
||||||
|
|
||||||
|
final Order order;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_SurveyDetailsSection> createState() =>
|
||||||
|
_SurveyDetailsSectionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SurveyDetailsSectionState extends ConsumerState<_SurveyDetailsSection> {
|
||||||
|
List<SurveyDetail> _rows = const [];
|
||||||
|
bool _dirty = false;
|
||||||
|
bool _saving = false;
|
||||||
|
|
||||||
|
Order get _order => widget.order;
|
||||||
|
bool get _editable => _order.surveyEditable;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_rows = List.of(_order.details);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant _SurveyDetailsSection oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
// 详情重新加载后同步服务端明细;本地有未保存改动时不覆盖。
|
||||||
|
if (!_dirty && !identical(oldWidget.order.details, _order.details)) {
|
||||||
|
_rows = List.of(_order.details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _editRow([int? index]) async {
|
||||||
|
final topology = _order.topology;
|
||||||
|
if (topology == null || topology.buildings.isEmpty) {
|
||||||
|
showAppToast('该客户暂无空间拓扑,无法维护勘察明细');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final result = await showModalBottomSheet<SurveyDetail>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: AppColors.surfaceContainerLowest,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)),
|
||||||
|
),
|
||||||
|
builder: (_) => _DetailEditorSheet(
|
||||||
|
topology: topology,
|
||||||
|
initial: index == null ? null : _rows[index],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (result == null || !mounted) return;
|
||||||
|
setState(() {
|
||||||
|
if (index == null) {
|
||||||
|
_rows = [..._rows, result];
|
||||||
|
} else {
|
||||||
|
_rows = [..._rows]..[index] = result;
|
||||||
|
}
|
||||||
|
_dirty = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmRemove(int index) async {
|
||||||
|
final ok = await showAppConfirm(
|
||||||
|
title: '删除明细',
|
||||||
|
content: '确定删除该条勘察明细吗?',
|
||||||
|
destructive: true,
|
||||||
|
);
|
||||||
|
if (!ok || !mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_rows = [..._rows]..removeAt(index);
|
||||||
|
_dirty = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
if (_saving) return;
|
||||||
|
// 建筑、楼层为必填要素,缺失的行提交前过滤。
|
||||||
|
final cleaned = _rows
|
||||||
|
.where((d) => d.buildingId != 0 && d.floorNo.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
if (cleaned.length != _rows.length) {
|
||||||
|
showAppToast('缺少建筑或楼层的明细行已忽略');
|
||||||
|
}
|
||||||
|
setState(() => _saving = true);
|
||||||
|
try {
|
||||||
|
final res = await ref
|
||||||
|
.read(orderApiProvider)
|
||||||
|
.saveSurveyDetails(_order.intId, cleaned);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (res.isOk && res.data != null) {
|
||||||
|
setState(() {
|
||||||
|
_rows = List.of(res.data!);
|
||||||
|
_dirty = false;
|
||||||
|
});
|
||||||
|
showAppToast('勘察明细已保存', success: true);
|
||||||
|
} else {
|
||||||
|
showAppToast(res.message.isEmpty ? '保存失败' : res.message);
|
||||||
|
}
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
if (mounted) showAppToast(e.message);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _saving = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final topology = _order.topology;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_header(),
|
||||||
|
if (topology != null && topology.buildings.isNotEmpty) ...[
|
||||||
|
_TopologyCard(topology: topology),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
if (_rows.isEmpty)
|
||||||
|
XCard(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 28),
|
||||||
|
child: const XPlaceholder(
|
||||||
|
text: '暂无勘察明细',
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
for (var i = 0; i < _rows.length; i++) ...[
|
||||||
|
if (i > 0) const SizedBox(height: 12),
|
||||||
|
_DetailRowCard(
|
||||||
|
detail: _rows[i],
|
||||||
|
editable: _editable,
|
||||||
|
onEdit: () => _editRow(i),
|
||||||
|
onDelete: () => _confirmRemove(i),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (_editable) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
XPrimaryButton(
|
||||||
|
label: '保存勘察明细',
|
||||||
|
loading: _saving,
|
||||||
|
onPressed: _dirty ? _save : null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _header() {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'勘察明细',
|
||||||
|
style: AppTypography.bodySm.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
letterSpacing: 0.26,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (_editable)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => _editRow(),
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.accentSubtle,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.regular),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'+ 新增明细',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 客户空间拓扑摘要:列出各建筑及其地上/地下层数。
|
||||||
|
class _TopologyCard extends StatelessWidget {
|
||||||
|
const _TopologyCard({required this.topology});
|
||||||
|
|
||||||
|
final CustomerTopology topology;
|
||||||
|
|
||||||
|
@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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (var i = 0; i < topology.buildings.length; i++) ...[
|
||||||
|
if (i > 0) const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const XIcon('location', size: 18, color: AppColors.primary),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
topology.buildings[i].buildingName,
|
||||||
|
style: AppTypography.bodyMd.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
_floorSummary(topology.buildings[i]),
|
||||||
|
style: AppTypography.labelCaps.copyWith(
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _floorSummary(TopologyBuilding b) {
|
||||||
|
final parts = <String>[];
|
||||||
|
if (b.floorCount > 0) parts.add('地上 ${b.floorCount} 层');
|
||||||
|
if (b.floorDownCount > 0) parts.add('地下 ${b.floorDownCount} 层');
|
||||||
|
return parts.isEmpty ? '暂无楼层' : parts.join(' · ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单条勘察明细的只读卡片。
|
||||||
|
class _DetailRowCard extends StatelessWidget {
|
||||||
|
const _DetailRowCard({
|
||||||
|
required this.detail,
|
||||||
|
required this.editable,
|
||||||
|
required this.onEdit,
|
||||||
|
required this.onDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
final SurveyDetail detail;
|
||||||
|
final bool editable;
|
||||||
|
final VoidCallback onEdit;
|
||||||
|
final VoidCallback onDelete;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final building = detail.buildingName.isEmpty
|
||||||
|
? '未知建筑'
|
||||||
|
: detail.buildingName;
|
||||||
|
final title = detail.floorLabel.isEmpty
|
||||||
|
? building
|
||||||
|
: '$building · ${detail.floorLabel}';
|
||||||
|
|
||||||
|
return XCard(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: AppTypography.bodyLg.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (editable) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_TextAction(
|
||||||
|
label: '编辑',
|
||||||
|
color: AppColors.primary,
|
||||||
|
onTap: onEdit,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
_TextAction(
|
||||||
|
label: '删除',
|
||||||
|
color: AppColors.statusError,
|
||||||
|
onTap: onDelete,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (detail.roomName.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_kv('房间', detail.roomName),
|
||||||
|
],
|
||||||
|
if (detail.remark.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_kv('备注', detail.remark),
|
||||||
|
],
|
||||||
|
if (detail.photoUrls.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
height: 92,
|
||||||
|
child: ListView.separated(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: detail.photoUrls.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(width: 10),
|
||||||
|
itemBuilder: (context, i) => GestureDetector(
|
||||||
|
onTap: () => _previewPhoto(context, detail.photoUrls[i]),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||||
|
child: Image.network(
|
||||||
|
detail.photoUrls[i],
|
||||||
|
width: 92,
|
||||||
|
height: 92,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, _, _) => Container(
|
||||||
|
width: 92,
|
||||||
|
height: 92,
|
||||||
|
color: AppColors.surfaceContainerHigh,
|
||||||
|
child: const XIcon(
|
||||||
|
'camera',
|
||||||
|
size: 24,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _kv(String label, String value) => Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 48,
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: AppTypography.labelCaps.copyWith(color: AppColors.textMuted),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: AppTypography.bodyMd.copyWith(
|
||||||
|
color: AppColors.onSurfaceVariant,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 文字操作(编辑 / 删除)。
|
||||||
|
class _TextAction extends StatelessWidget {
|
||||||
|
const _TextAction({
|
||||||
|
required this.label,
|
||||||
|
required this.color,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final Color color;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 2),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: color),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 明细编辑弹层:建筑 / 楼层(必填)、房间(选填)、现场照片、备注。
|
||||||
|
class _DetailEditorSheet extends ConsumerStatefulWidget {
|
||||||
|
const _DetailEditorSheet({required this.topology, this.initial});
|
||||||
|
|
||||||
|
final CustomerTopology topology;
|
||||||
|
final SurveyDetail? initial;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_DetailEditorSheet> createState() => _DetailEditorSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DetailEditorSheetState extends ConsumerState<_DetailEditorSheet> {
|
||||||
|
late final TextEditingController _remark;
|
||||||
|
late List<({String key, String url})> _photos;
|
||||||
|
|
||||||
|
int? _buildingId;
|
||||||
|
String _floorNo = '';
|
||||||
|
int? _roomId;
|
||||||
|
String _roomName = '';
|
||||||
|
bool _uploading = false;
|
||||||
|
|
||||||
|
CustomerTopology get _topology => widget.topology;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final initial = widget.initial;
|
||||||
|
_buildingId = (initial?.buildingId ?? 0) == 0 ? null : initial!.buildingId;
|
||||||
|
_floorNo = initial?.floorNo ?? '';
|
||||||
|
_roomId = initial?.roomId;
|
||||||
|
_roomName = initial?.roomName ?? '';
|
||||||
|
_remark = TextEditingController(text: initial?.remark ?? '');
|
||||||
|
final keys = initial?.photos ?? const <String>[];
|
||||||
|
final urls = initial?.photoUrls ?? const <String>[];
|
||||||
|
_photos = [
|
||||||
|
for (var i = 0; i < keys.length; i++)
|
||||||
|
(key: keys[i], url: i < urls.length ? urls[i] : ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_remark.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
TopologyBuilding? get _building =>
|
||||||
|
_buildingId == null ? null : _topology.buildingOf(_buildingId!);
|
||||||
|
|
||||||
|
Future<void> _pickChoice(
|
||||||
|
List<String> labels,
|
||||||
|
int current,
|
||||||
|
ValueChanged<int> onSelected,
|
||||||
|
) async {
|
||||||
|
if (labels.isEmpty) return;
|
||||||
|
final index = await showModalBottomSheet<int>(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: AppColors.surfaceContainerLowest,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)),
|
||||||
|
),
|
||||||
|
builder: (ctx) => SafeArea(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxHeight: MediaQuery.of(ctx).size.height * 0.6,
|
||||||
|
),
|
||||||
|
child: ListView(
|
||||||
|
shrinkWrap: true,
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < labels.length; i++)
|
||||||
|
ListTile(
|
||||||
|
title: Text(
|
||||||
|
labels[i],
|
||||||
|
style: AppTypography.bodyMd.copyWith(
|
||||||
|
color: AppColors.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: i == current
|
||||||
|
? const XIcon('check', size: 18, color: AppColors.primary)
|
||||||
|
: null,
|
||||||
|
onTap: () => Navigator.of(ctx).pop(i),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (index != null) onSelected(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pickBuilding() {
|
||||||
|
final buildings = _topology.buildings;
|
||||||
|
final current = buildings.indexWhere((b) => b.id == _buildingId);
|
||||||
|
_pickChoice(buildings.map((b) => b.buildingName).toList(), current, (i) {
|
||||||
|
final b = buildings[i];
|
||||||
|
setState(() {
|
||||||
|
_buildingId = b.id;
|
||||||
|
// 建筑变更后楼层/房间重新选择
|
||||||
|
_floorNo = '';
|
||||||
|
_roomId = null;
|
||||||
|
_roomName = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pickFloor() {
|
||||||
|
final options = _building?.floorOptions ?? const [];
|
||||||
|
final current = options.indexWhere((o) => o.value == _floorNo);
|
||||||
|
_pickChoice(options.map((o) => o.label).toList(), current, (i) {
|
||||||
|
setState(() {
|
||||||
|
_floorNo = options[i].value;
|
||||||
|
_roomId = null;
|
||||||
|
_roomName = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pickRoom() {
|
||||||
|
final building = _building;
|
||||||
|
if (building == null || _floorNo.isEmpty) return;
|
||||||
|
final rooms = _topology.roomsOf(building.id, _floorNo);
|
||||||
|
if (rooms.isEmpty) return;
|
||||||
|
final labels = ['不选择房间', ...rooms.map((r) => r.roomName)];
|
||||||
|
final current = rooms.indexWhere((r) => r.id == _roomId) + 1;
|
||||||
|
_pickChoice(labels, current, (i) {
|
||||||
|
setState(() {
|
||||||
|
if (i == 0) {
|
||||||
|
_roomId = null;
|
||||||
|
_roomName = '';
|
||||||
|
} else {
|
||||||
|
_roomId = rooms[i - 1].id;
|
||||||
|
_roomName = rooms[i - 1].roomName;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addPhoto() async {
|
||||||
|
if (_uploading) return;
|
||||||
|
final source = await showModalBottomSheet<ImageSource>(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: AppColors.surfaceContainerLowest,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)),
|
||||||
|
),
|
||||||
|
builder: (ctx) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
title: Text(
|
||||||
|
'拍照',
|
||||||
|
style: AppTypography.bodyMd.copyWith(color: AppColors.onSurface),
|
||||||
|
),
|
||||||
|
onTap: () => Navigator.of(ctx).pop(ImageSource.camera),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
title: Text(
|
||||||
|
'从相册选择',
|
||||||
|
style: AppTypography.bodyMd.copyWith(color: AppColors.onSurface),
|
||||||
|
),
|
||||||
|
onTap: () => Navigator.of(ctx).pop(ImageSource.gallery),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (source == null || !mounted) return;
|
||||||
|
|
||||||
|
final file = await ImagePicker().pickImage(source: source, imageQuality: 80);
|
||||||
|
if (file == null || !mounted) return;
|
||||||
|
|
||||||
|
setState(() => _uploading = true);
|
||||||
|
try {
|
||||||
|
final up = await ref.read(apiClientProvider).uploadImage(file.path);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _photos = [..._photos, (key: up.key, url: up.url)]);
|
||||||
|
} catch (e) {
|
||||||
|
final message = e is ApiException && e.message.isNotEmpty
|
||||||
|
? e.message
|
||||||
|
: '照片上传失败';
|
||||||
|
if (mounted) showAppToast(message);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _uploading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _confirm() {
|
||||||
|
final building = _building;
|
||||||
|
if (building == null) {
|
||||||
|
showAppToast('请选择建筑');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_floorNo.isEmpty) {
|
||||||
|
showAppToast('请选择楼层');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.of(context).pop(
|
||||||
|
SurveyDetail(
|
||||||
|
id: widget.initial?.id ?? 0,
|
||||||
|
buildingId: building.id,
|
||||||
|
buildingName: building.buildingName,
|
||||||
|
floorNo: _floorNo,
|
||||||
|
roomId: _roomId,
|
||||||
|
roomName: _roomName,
|
||||||
|
photos: _photos
|
||||||
|
.map((p) => p.key)
|
||||||
|
.where((k) => k.isNotEmpty)
|
||||||
|
.toList(),
|
||||||
|
photoUrls: _photos.map((p) => p.url).toList(),
|
||||||
|
remark: _remark.text.trim(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final building = _building;
|
||||||
|
final floorOptions = building?.floorOptions ?? const [];
|
||||||
|
final floorLabel = floorOptions
|
||||||
|
.where((o) => o.value == _floorNo)
|
||||||
|
.map((o) => o.label)
|
||||||
|
.firstOrNull;
|
||||||
|
final rooms = building == null || _floorNo.isEmpty
|
||||||
|
? const <TopologyRoom>[]
|
||||||
|
: _topology.roomsOf(building.id, _floorNo);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
|
||||||
|
child: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.initial == null ? '新增勘察明细' : '编辑勘察明细',
|
||||||
|
style: AppTypography.headlineMd.copyWith(
|
||||||
|
color: AppColors.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_field(
|
||||||
|
label: '建筑',
|
||||||
|
required: true,
|
||||||
|
child: _pickerBox(
|
||||||
|
building?.buildingName ?? '',
|
||||||
|
'请选择建筑',
|
||||||
|
_pickBuilding,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_field(
|
||||||
|
label: '楼层',
|
||||||
|
required: true,
|
||||||
|
child: _pickerBox(
|
||||||
|
floorLabel ?? '',
|
||||||
|
building == null
|
||||||
|
? '请先选择建筑'
|
||||||
|
: (floorOptions.isEmpty ? '暂无可选楼层' : '请选择楼层'),
|
||||||
|
building == null || floorOptions.isEmpty ? null : _pickFloor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_field(
|
||||||
|
label: '房间',
|
||||||
|
child: _pickerBox(
|
||||||
|
_roomName,
|
||||||
|
_floorNo.isEmpty
|
||||||
|
? '请先选择楼层'
|
||||||
|
: (rooms.isEmpty ? '暂无房间' : '选择房间(选填)'),
|
||||||
|
rooms.isEmpty ? null : _pickRoom,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_field(
|
||||||
|
label: '现场照片',
|
||||||
|
child: _photoGrid(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_field(
|
||||||
|
label: '备注',
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: _fieldDecoration,
|
||||||
|
child: TextField(
|
||||||
|
controller: _remark,
|
||||||
|
maxLines: 4,
|
||||||
|
minLines: 3,
|
||||||
|
style: AppTypography.bodyMd.copyWith(
|
||||||
|
color: AppColors.onSurface,
|
||||||
|
),
|
||||||
|
cursorColor: AppColors.primary,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
border: InputBorder.none,
|
||||||
|
hintText: '补充说明(选填)',
|
||||||
|
hintStyle: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
color: AppColors.textPlaceholder,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: XPrimaryButton(
|
||||||
|
label: '取消',
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
background: AppColors.surface,
|
||||||
|
foreground: AppColors.primary,
|
||||||
|
height: 48,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: XPrimaryButton(
|
||||||
|
label: '确定',
|
||||||
|
onPressed: _uploading ? null : _confirm,
|
||||||
|
height: 48,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _field({
|
||||||
|
required String label,
|
||||||
|
required Widget child,
|
||||||
|
bool required = false,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: AppTypography.bodySm.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (required)
|
||||||
|
Text(
|
||||||
|
' *',
|
||||||
|
style: AppTypography.bodySm.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.statusError,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _photoGrid() {
|
||||||
|
return SizedBox(
|
||||||
|
height: 84,
|
||||||
|
child: ListView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < _photos.length; i++) ...[
|
||||||
|
Stack(
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||||
|
child: _photos[i].url.isNotEmpty
|
||||||
|
? Image.network(
|
||||||
|
_photos[i].url,
|
||||||
|
width: 84,
|
||||||
|
height: 84,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, _, _) => Container(
|
||||||
|
width: 84,
|
||||||
|
height: 84,
|
||||||
|
color: AppColors.surfaceContainerHigh,
|
||||||
|
child: const XIcon(
|
||||||
|
'camera',
|
||||||
|
size: 22,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
width: 84,
|
||||||
|
height: 84,
|
||||||
|
color: AppColors.surfaceContainerHigh,
|
||||||
|
child: const XIcon(
|
||||||
|
'camera',
|
||||||
|
size: 22,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => setState(() {
|
||||||
|
_photos = [..._photos]..removeAt(i);
|
||||||
|
}),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0x8C0D1C2C),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const XIcon(
|
||||||
|
'close',
|
||||||
|
size: 12,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
],
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _uploading ? null : _addPhoto,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: Container(
|
||||||
|
width: 84,
|
||||||
|
height: 84,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.surfaceBright,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||||
|
border: Border.all(color: AppColors.borderRule),
|
||||||
|
),
|
||||||
|
child: _uploading
|
||||||
|
? const Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
valueColor: AlwaysStoppedAnimation(AppColors.primary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: const [
|
||||||
|
XIcon('camera', size: 24, color: AppColors.primary),
|
||||||
|
SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'添加',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _pickerBox(String value, String placeholder, VoidCallback? onTap) {
|
||||||
|
final empty = value.isEmpty;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
height: 44,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
|
decoration: _fieldDecoration,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
empty ? placeholder : value,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: AppTypography.bodyMd.copyWith(
|
||||||
|
color: empty ? AppColors.textPlaceholder : AppColors.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (onTap != null)
|
||||||
|
const XIcon('forward', size: 16, color: AppColors.outline),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
BoxDecoration get _fieldDecoration => BoxDecoration(
|
||||||
|
color: AppColors.surfaceBright,
|
||||||
|
border: Border.all(color: AppColors.borderRule),
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.regular),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -68,7 +68,7 @@ class ProfileTab extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
_profileCard(worker, profile?.primaryCert),
|
_profileCard(worker, profile?.primaryCert),
|
||||||
_menuSection(),
|
_menuSection(context),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: _logoutButton(ref),
|
child: _logoutButton(ref),
|
||||||
@@ -323,25 +323,29 @@ class ProfileTab extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
/// 两组 `ios-card` 菜单,行点击仅提示(同源实现)。
|
/// 两组 `ios-card` 菜单,除「修改密码」跳转外其余行点击仅提示(同源实现)。
|
||||||
Widget _menuSection() {
|
Widget _menuSection(BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_menuGroup(const [
|
_menuGroup(context, const [
|
||||||
('order', '我的订单'),
|
('order', '我的订单', null),
|
||||||
('earnings', '收益记录'),
|
('earnings', '收益记录', null),
|
||||||
('notify', '账户设置'),
|
('settings', '修改密码', '/change-password'),
|
||||||
|
('notify', '账户设置', null),
|
||||||
]),
|
]),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_menuGroup(const [('star', '帮助中心'), ('shandian', '关于我们')]),
|
_menuGroup(context, const [
|
||||||
|
('star', '帮助中心', null),
|
||||||
|
('shandian', '关于我们', null),
|
||||||
|
]),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _menuGroup(List<(String, String)> items) {
|
Widget _menuGroup(BuildContext context, List<(String, String, String?)> items) {
|
||||||
return XCard(
|
return XCard(
|
||||||
radius: AppRadius.lg,
|
radius: AppRadius.lg,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
@@ -354,16 +358,16 @@ class ProfileTab extends ConsumerWidget {
|
|||||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
color: AppColors.borderSoft,
|
color: AppColors.borderSoft,
|
||||||
),
|
),
|
||||||
_menuRow(items[i].$1, items[i].$2),
|
_menuRow(context, items[i].$1, items[i].$2, items[i].$3),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _menuRow(String icon, String label) {
|
Widget _menuRow(BuildContext context, String icon, String label, String? route) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => showAppToast(label),
|
onTap: () => route == null ? showAppToast(label) : context.push(route),
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
|
|||||||
+113
-1
@@ -89,6 +89,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.15.1"
|
version: "1.15.1"
|
||||||
|
cross_file:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cross_file
|
||||||
|
sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.5+5"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -145,6 +153,38 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
version: "7.0.1"
|
||||||
|
file_selector_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_linux
|
||||||
|
sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.4+1"
|
||||||
|
file_selector_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_macos
|
||||||
|
sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.5+1"
|
||||||
|
file_selector_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_platform_interface
|
||||||
|
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.0"
|
||||||
|
file_selector_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_windows
|
||||||
|
sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.3+6"
|
||||||
fixnum:
|
fixnum:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -171,6 +211,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
flutter_plugin_android_lifecycle:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_plugin_android_lifecycle
|
||||||
|
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.35"
|
||||||
flutter_riverpod:
|
flutter_riverpod:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -245,6 +293,70 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
image_picker:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: image_picker
|
||||||
|
sha256: e71b1bd9e40124041128d60fc3034c7b1598e1bba4e570bba0c70301c817013a
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.4"
|
||||||
|
image_picker_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_android
|
||||||
|
sha256: d5b3e1774af29c9ab00103afb0d4614070f924d2e0057ac867ec98800114793f
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.8.13+17"
|
||||||
|
image_picker_for_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_for_web
|
||||||
|
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.1"
|
||||||
|
image_picker_ios:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_ios
|
||||||
|
sha256: "4fb88d4350ffc208c2a09fc321db1f80282688716b8cfe10c8c80acc5ae8934a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.8.13+10"
|
||||||
|
image_picker_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_linux
|
||||||
|
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.2"
|
||||||
|
image_picker_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_macos
|
||||||
|
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.2+1"
|
||||||
|
image_picker_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_platform_interface
|
||||||
|
sha256: "2dee4b4e3be429db71472c9c559c981498070a61af1ba82e0e4986fc5dadc042"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.11.2"
|
||||||
|
image_picker_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_windows
|
||||||
|
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.2"
|
||||||
intl:
|
intl:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -740,4 +852,4 @@ packages:
|
|||||||
version: "3.1.4"
|
version: "3.1.4"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.11.5 <4.0.0"
|
dart: ">=3.11.5 <4.0.0"
|
||||||
flutter: ">=3.38.0"
|
flutter: ">=3.41.0"
|
||||||
@@ -19,6 +19,7 @@ dependencies:
|
|||||||
dio: ^5.11.1
|
dio: ^5.11.1
|
||||||
shared_preferences: ^2.5.5
|
shared_preferences: ^2.5.5
|
||||||
flutter_svg: ^2.3.0
|
flutter_svg: ^2.3.0
|
||||||
|
image_picker: ^1.2.3
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in new issue
Block a user