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

@@ -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),
);
}