feat: 新增勘察明细管理与修改密码功能
This commit is contained in:
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),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user