feat: 新增勘察明细管理与修改密码功能

This commit is contained in:
andyesfly committed 2026-10-17 10:10:09 +08:00
1 parent 56830e94ec
commit 774403785c
12 files changed
+1553 -18

No files matched your search

+8
View File
@@ -55,4 +55,12 @@ class AuthApi {
},
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();
/// 点击消息:先标记已读;勘察指派消息(`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 {
if (notice.isRead) return;
try {
@@ -231,7 +241,7 @@ class _NoticeCenterPageState extends ConsumerState<NoticeCenterPage> {
final notice = _items[index];
return _NoticeTile(
notice: notice,
onTap: notice.isRead ? null : () => _markRead(notice),
onTap: () => _onNoticeTap(notice),
);
},
),
@@ -281,7 +291,7 @@ class _NoticeCenterPageState extends ConsumerState<NoticeCenterPage> {
}
}
/// 单条消息卡片:未读有蓝底高亮 + 红点,点击标记已读。
/// 单条消息卡片:未读有蓝底高亮 + 红点,点击标记已读并按业务类型跳转。
class _NoticeTile extends StatelessWidget {
const _NoticeTile({required this.notice, this.onTap});
+13
View File
@@ -33,6 +33,19 @@ class OrderApi {
Future<ApiResponse<void>> grab(String source, int id) =>
_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。
Future<ApiResponse<void>> complete(String source, int id) =>
_client.put<void>('/worker-app/orders/$source/$id/complete');
+173
View File
@@ -22,6 +22,8 @@ class Order {
required this.laborFee,
required this.materialFee,
required this.estimatedDuration,
this.details = const [],
this.topology,
});
/// `repair-12`
@@ -52,6 +54,18 @@ class Order {
final double materialFee;
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) {
'repair' => '家庭电路维修',
@@ -109,5 +123,164 @@ class Order {
laborFee: toDoubleOr(json['laborFee'], 0),
materialFee: toDoubleOr(json['materialFee'], 0),
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_riverpod/flutter_riverpod.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_dimens.dart';
import '../../../core/theme/app_typography.dart';
@@ -98,7 +101,10 @@ class OrderDetailPage extends ConsumerWidget {
const SizedBox(height: 12),
_CustomerCard(order: order),
const SizedBox(height: 16),
_PhotosSection(photos: order.photos),
if (order.isSurvey)
_SurveyDetailsSection(order: order)
else
_PhotosSection(photos: order.photos),
const SizedBox(height: 16),
_FinancialCard(order: order),
const SizedBox(height: 16),
@@ -737,3 +743,939 @@ class _DotPatternPainter extends CustomPainter {
@override
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),
_menuSection(),
_menuSection(context),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: _logoutButton(ref),
@@ -323,25 +323,29 @@ class ProfileTab extends ConsumerWidget {
],
);
/// 两组 `ios-card` 菜单,行点击仅提示(同源实现)。
Widget _menuSection() {
/// 两组 `ios-card` 菜单,除「修改密码」跳转外其余行点击仅提示(同源实现)。
Widget _menuSection(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
child: Column(
children: [
_menuGroup(const [
('order', '我的订单'),
('earnings', '收益记录'),
('notify', '账户设置'),
_menuGroup(context, const [
('order', '我的订单', null),
('earnings', '收益记录', null),
('settings', '修改密码', '/change-password'),
('notify', '账户设置', null),
]),
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(
radius: AppRadius.lg,
padding: const EdgeInsets.symmetric(vertical: 4),
@@ -354,16 +358,16 @@ class ProfileTab extends ConsumerWidget {
margin: const EdgeInsets.symmetric(horizontal: 16),
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(
onTap: () => showAppToast(label),
onTap: () => route == null ? showAppToast(label) : context.push(route),
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.all(16),