已完成用户APP首期功能开发

交付用户端首期页面、配套接口、后台资源及测试文档。用户APP构建、静态分析和三个管理后台构建通过;完整测试仍有2项失败,后端模型注释检查未通过,详见交付记录。
This commit is contained in:
czl231
2026-09-13 00:57:32 +08:00
parent a0a4bb1218
commit 3ef33b531d
793 changed files with 40959 additions and 1204 deletions

View File

@@ -0,0 +1,226 @@
// 功能描述:图05报修步骤、表单分区与联系人预览,布局不持有业务状态。
// 版本:1.0.0。
import 'package:flutter/material.dart';
import '../../../domain/models/shipping_address.dart';
/// 三步进度随当前步骤展示,文字放大时允许换行。
class RepairStepHeader extends StatelessWidget {
const RepairStepHeader({required this.step, super.key});
final int step;
@override
Widget build(BuildContext context) => Row(
children: [
for (var index = 0; index < 3; index++) ...[
if (index > 0) const SizedBox(width: 12, child: Divider(color: Color(0xFFDFE2E9))),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
children: [
Container(
width: 22,
height: 22,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: index == step ? const Color(0xFF1762F4) : Colors.transparent,
border: Border.all(
color: index == step ? const Color(0xFF1762F4) : const Color(0xFFD2D6E0),
),
),
child: Text(
'${index + 1}',
style: TextStyle(
fontSize: 14,
color: index == step ? Colors.white : const Color(0xFF8D929E),
),
),
),
const SizedBox(width: 5),
Expanded(
child: Text(
['故障信息', '联系与地址', '确认提交'][index],
style: TextStyle(
fontSize: 13,
color: index == step ? const Color(0xFF1762F4) : const Color(0xFF818694),
fontWeight: index == step ? FontWeight.w600 : FontWeight.normal,
),
),
),
],
),
),
),
],
],
);
}
/// 原设计中的连续表单分区;内部组件不再各自套卡片。
class RepairSection extends StatelessWidget {
const RepairSection({required this.child, super.key});
final Widget child;
@override
Widget build(BuildContext context) => Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFDFE2E9)),
),
child: Material(color: Colors.transparent, child: child),
);
}
/// 必填标记有明确文字标签,不依赖颜色单独表达含义。
class RequiredRepairLabel extends StatelessWidget {
const RequiredRepairLabel(this.label, {super.key});
final String label;
@override
Widget build(BuildContext context) => Text.rich(
TextSpan(
children: [
TextSpan(text: label),
const TextSpan(
text: ' *',
style: TextStyle(color: Color(0xFFE53935)),
),
],
),
semanticsLabel: '$label,必填',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
);
}
/// 只显示实际选中地址,无数据时提供选取入口,不伪造定位成功。
class RepairContactPreview extends StatelessWidget {
const RepairContactPreview({required this.address, required this.onChoose, super.key});
final ShippingAddress? address;
final VoidCallback onChoose;
@override
Widget build(BuildContext context) => RepairSection(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text.rich(
TextSpan(
children: [
TextSpan(
text: '联系与地址',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
TextSpan(
text: ' (下一步填写)',
style: TextStyle(fontSize: 12, color: Color(0xFF8D929E)),
),
],
),
),
const SizedBox(height: 4),
_row(
icon: Icons.phone_outlined,
title: '联系电话',
subtitle: address?.contactName,
trailing: address?.maskedPhone ?? '请选择',
),
const Divider(height: 1, color: Color(0xFFDFE2E9)),
_row(
icon: Icons.location_on_outlined,
title: '报修地址',
subtitle: address?.address ?? '选择报修地址',
),
],
),
);
Widget _row({
required IconData icon,
required String title,
String? subtitle,
String? trailing,
}) => InkWell(
onTap: onChoose,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
_icon(icon),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(title, style: const TextStyle(fontSize: 14)),
if (subtitle != null)
Text(
subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, color: Color(0xFF626A7A)),
),
],
),
),
if (trailing != null)
Text(trailing, style: const TextStyle(fontSize: 13, color: Color(0xFF626A7A))),
const Icon(Icons.chevron_right, size: 20),
],
),
),
);
Widget _icon(IconData icon) => Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: const Color(0xFFEDF3FF),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, size: 23, color: const Color(0xFF1762F4)),
);
}
/// 紧急情况提示保持与设计稿同层级,拨号只在用户明确点击后触发。
class RepairEmergencyNotice extends StatelessWidget {
const RepairEmergencyNotice({required this.onCall, super.key});
final VoidCallback onCall;
@override
Widget build(BuildContext context) => Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(10, 8, 4, 8),
decoration: BoxDecoration(
color: const Color(0xFFFFF7E8),
border: Border.all(color: const Color(0xFFFFD591)),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.warning_amber_rounded, color: Color(0xFFEB7B16), size: 19),
const SizedBox(width: 6),
const Expanded(
child: Text(
'若发生燃气泄漏,请先关闭阀门、开窗通风并远离明火',
style: TextStyle(fontSize: 10, color: Color(0xFF9A4D00), height: 1.4),
),
),
TextButton(
onPressed: onCall,
style: TextButton.styleFrom(
foregroundColor: const Color(0xFFD65A00),
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 6),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('紧急电话', style: TextStyle(fontSize: 11)),
Icon(Icons.chevron_right, size: 15),
],
),
),
],
),
);
}

View File

@@ -0,0 +1,788 @@
// 功能描述:报修故障、联系地址、确认提交三步表单;未知结果重试复用原请求。
// 版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:uuid/uuid.dart';
import '../../../data/repositories/client_repository.dart';
import '../../../data/services/api_client.dart';
import '../../../data/services/repair_draft_store.dart';
import '../../../domain/models/shipping_address.dart';
import 'repair_photos.dart';
import 'repair_layout.dart';
import 'repair_speech_button.dart';
typedef RepairEmergencyLauncher = Future<bool> Function(Uri uri);
class RepairPage extends StatefulWidget {
const RepairPage({
required this.repository,
this.pickPhoto,
this.draftStore,
this.launchEmergency,
super.key,
});
final ClientRepository repository;
final RepairPhotoPicker? pickPhoto;
final RepairDraftStore? draftStore;
final RepairEmergencyLauncher? launchEmergency;
@override
State<RepairPage> createState() => _RepairPageState();
}
class _RepairPageState extends State<RepairPage> {
bool _dictating = false;
final _description = TextEditingController();
String _requestNo = const Uuid().v7();
final _photos = <RepairPhoto>[];
bool _picking = false;
static const _faults = {'valve': '阀门故障', 'alarm': '报警器故障', 'leak': '燃气泄漏', 'other': '其他问题'};
String _fault = 'valve';
ShippingAddress? _address;
DateTime? _appointment;
int _step = 0;
bool _busy = false, _attempted = false, _leaving = false;
String? _error;
String? _draftOwner;
bool _initializing = false, _draftLoadFailed = false;
bool _savingDraft = false;
@override
void initState() {
super.initState();
if (widget.draftStore != null) {
_initializing = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _loadDraft();
});
}
}
/// 账户确认后才读取草稿;恢复需要用户选择,绝不自动重放提交。
Future<void> _loadDraft() async {
setState(() {
_initializing = true;
_draftLoadFailed = false;
_error = null;
});
try {
_draftOwner = await widget.repository.repairDraftOwner();
final draft = await widget.draftStore!.read(_draftOwner!);
if (!mounted || draft == null) return;
final pending = draft['attempted'] == true;
final choice = await showDialog<String>(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Text('发现报修草稿'),
content: Text(pending ? '上次提交结果待确认。恢复后可使用原请求号重试,或先查看工单。' : '继续填写上次暂存的故障、照片和联系地址?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, pending ? 'orders' : 'discard'),
child: Text(pending ? '查看工单' : '删除草稿'),
),
FilledButton(
onPressed: () => Navigator.pop(context, 'restore'),
child: const Text('恢复草稿'),
),
],
),
);
if (!mounted) return;
if (choice == 'orders') {
_leaving = true;
context.go('/orders?tab=tickets');
return;
}
if (choice == 'discard') {
await widget.draftStore!.delete(_draftOwner!);
return;
}
if (choice != 'restore') throw const FormatException('草稿尚未恢复');
final request = draft['request_no'],
description = draft['description'],
fault = draft['fault_type'],
step = draft['step'];
if (request is! String ||
request.isEmpty ||
request.length > 128 ||
description is! String ||
description.length > 2000 ||
fault is! String ||
!_faults.containsKey(fault) ||
step is! int ||
step < 0 ||
step > 2) {
throw const FormatException('草稿字段无效');
}
final rawPhotos = draft['photos'];
if (rawPhotos is! List || rawPhotos.length > 3) throw const FormatException('草稿照片无效');
final photos = <RepairPhoto>[];
for (final item in rawPhotos) {
if (item is! Map ||
item['uri'] is! String ||
!['camera', 'gallery'].contains(item['source'])) {
throw const FormatException('草稿照片无效');
}
final uri = item['uri'] as String;
final bytes = await widget.repository.uploadedTicketPhoto(uri);
if (bytes == null) throw const ApiException(404, '草稿照片已不可读取');
photos.add(
RepairPhoto(
bytes: bytes,
filename: uri.endsWith('.png') ? 'repair.png' : 'repair.jpg',
source: item['source'] as String,
addedAt: DateTime.parse(item['added_at'] as String),
)..uri = uri,
);
}
final rawAddress = draft['address'];
var address = rawAddress is Map
? ShippingAddress.fromJson(Map<String, Object?>.from(rawAddress))
: null;
if ((step == 2 || pending) && (address == null || photos.isEmpty)) {
throw const FormatException('提交草稿不完整');
}
var restoredStep = step;
// 未发出的草稿使用最新本人地址;未知提交保留首次请求快照以便幂等查询。
if (!pending && address != null) {
final identity = address.identity;
final current = await widget.repository.shippingAddresses();
address = null;
for (final candidate in current) {
if (candidate.identity == identity) address = candidate;
}
if (address == null && restoredStep > 1) restoredStep = 1;
}
final appointment = draft['appointment_at'] is String
? DateTime.parse(draft['appointment_at'] as String).toLocal()
: null;
if (!mounted) return;
setState(() {
_requestNo = request;
_description.text = description;
_fault = fault;
_step = restoredStep;
_attempted = pending;
_photos
..clear()
..addAll(photos);
_address = address;
_appointment = appointment;
});
} catch (error) {
if (mounted && error is! SessionExpiredException) {
setState(() {
_draftLoadFailed = true;
_error = '草稿读取失败,请重试或先查看工单。';
});
}
} finally {
if (mounted) setState(() => _initializing = false);
}
}
/// 保存前上传照片,仅持久化小体积受控URI与稳定请求号。
Future<void> _persistDraft() async {
if (widget.draftStore == null) return;
if (_draftOwner == null || await widget.repository.repairDraftOwner() != _draftOwner) {
throw const ApiException(1104, '草稿账户已变化,请重新打开页面');
}
for (final photo in _photos) {
photo.uri ??= await widget.repository.uploadTicketPhoto(photo.bytes, photo.filename);
}
await widget.draftStore!.write(_draftOwner!, {
'schema': 1,
'request_no': _requestNo,
'description': _description.text.trim(),
'fault_type': _fault,
'step': _step,
'attempted': _attempted,
'address': _address == null ? null : {..._address!.toJson(), 'identity': _address!.identity},
'appointment_at': _appointment?.toUtc().toIso8601String(),
'photos': _photos.map((p) => p.toJson()).toList(),
});
}
Future<void> _saveAndExit() async {
if (_dictating) return;
if (_busy || _picking || _initializing) return;
setState(() {
_busy = true;
_savingDraft = true;
_error = null;
});
try {
await _persistDraft();
if (mounted) {
_leaving = true;
context.go(_attempted ? '/orders?tab=tickets' : '/me');
}
} catch (error) {
if (mounted) setState(() => _error = '草稿保存失败,内容仍在当前页面,请重试。');
} finally {
if (mounted) {
setState(() {
_busy = false;
_savingDraft = false;
});
}
}
}
/// 损坏草稿只有用户明确选择才清除,避免自动丢弃可能已提交的请求号。
Future<void> _discardUnreadableDraft() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('清除无法读取的草稿?'),
content: const Text('若之前提交结果未知,请先检查报修工单,避免重新创建相同申请。'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('返回')),
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('清除草稿')),
],
),
);
if (confirmed != true || !mounted) return;
try {
await widget.draftStore!.delete(_draftOwner!);
if (mounted) {
setState(() {
_draftLoadFailed = false;
_error = null;
_description.clear();
_photos.clear();
_address = null;
_appointment = null;
_step = 0;
_attempted = false;
_requestNo = const Uuid().v7();
});
}
} catch (_) {
if (mounted) setState(() => _error = '草稿清理失败,请重试。');
}
}
@override
void dispose() {
_description.dispose();
super.dispose();
}
/// 选择用户自己的地址,必须具有完整联系人,以便服务端生成独立快照。
Future<void> _chooseAddress() async {
if (_dictating) return;
final value = await context.push<ShippingAddress>('/addresses?select=1');
if (value != null && mounted) setState(() => _address = value);
}
Future<void> _chooseTime() async {
final now = DateTime.now();
final date = await showDatePicker(
context: context,
initialDate: _appointment ?? now,
firstDate: DateTime(now.year, now.month, now.day),
lastDate: DateTime(now.year + 1, now.month, now.day),
);
if (date == null || !mounted) return;
final time = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(_appointment ?? now.add(const Duration(hours: 1))),
);
if (time == null || !mounted) return;
final value = DateTime(date.year, date.month, date.day, time.hour, time.minute);
setState(() {
if (value.isBefore(DateTime.now())) {
_error = '请选择将来的预约时间';
} else {
_appointment = value;
_error = null;
}
});
}
/// 未提交前可修改,结果未知时冻结原请求内容,避免同一请求号对应不同业务。
Future<void> _next() async {
if (_dictating) return;
if (_busy || _picking) return;
if (_step == 0 && _description.text.trim().isEmpty) {
setState(() => _error = '请描述故障现象');
return;
}
if (_step == 0 && _photos.isEmpty) {
setState(() => _error = '请添加至少一张现场照片');
return;
}
if (_step == 1 &&
(_address == null || _address!.contactName.isEmpty || _address!.contactPhone.isEmpty)) {
setState(() => _error = '请选择并补齐联系人、电话和地址');
return;
}
if (_step < 2) {
setState(() {
_step++;
_error = null;
});
return;
}
final previousAttempted = _attempted;
var sent = false;
setState(() {
_busy = true;
_attempted = true;
_error = null;
});
try {
if (widget.draftStore != null) {
await _persistDraft();
} else {
for (final photo in _photos) {
photo.uri ??= await widget.repository.uploadTicketPhoto(photo.bytes, photo.filename);
}
}
if (!mounted) return;
sent = true;
final identity = await widget.repository.submitRepair(
requestNo: _requestNo,
description: _description.text.trim(),
faultType: _fault,
addressIdentity: _address!.identity,
appointment: _appointment,
photos: _photos.map((photo) => photo.toJson()).toList(),
);
if (!mounted) return;
try {
if (_draftOwner != null) await widget.draftStore?.delete(_draftOwner!);
} catch (_) {
/* 已成功提交,保留原请求号仍可幂等恢复。 */
}
if (!mounted) return;
setState(() => _leaving = true);
context.go('/tickets/${Uri.encodeComponent(identity)}');
} catch (error) {
if (mounted && error is! SessionExpiredException) {
setState(() {
_error = error is ApiException ? error.message : '提交结果待确认,请重试或查看工单';
if (!sent) {
_attempted = previousAttempted;
_error = '提交前保存失败,尚未发送工单,请重试。';
}
// 明确校验拒绝时可修正输入;网络未知结果保持同一份请求。
if (error is ApiException && [1711, 1112, 1704].contains(error.code)) _attempted = false;
});
}
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _back() async {
if (_dictating) return;
if (_busy || _picking) return;
if (_step > 0 && !_attempted) {
setState(() {
_step--;
_error = null;
});
return;
}
if (widget.draftStore != null &&
(_description.text.isNotEmpty || _photos.isNotEmpty || _attempted)) {
final choice = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('保存报修草稿?'),
content: Text(_attempted ? '提交结果待确认,将保留原请求号供下次查看。' : '保存故障、照片与地址,下次可继续填写。'),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('继续填写')),
if (!_attempted)
TextButton(
onPressed: () => Navigator.pop(context, 'discard'),
child: const Text('不保存'),
),
FilledButton(
onPressed: () => Navigator.pop(context, 'save'),
child: const Text('暂存并退出'),
),
],
),
);
if (!mounted || choice == null) return;
if (choice == 'save') {
await _saveAndExit();
return;
}
try {
await widget.draftStore!.delete(_draftOwner!);
} catch (_) {
if (mounted) setState(() => _error = '草稿清理失败,请重试');
return;
}
if (mounted) {
_leaving = true;
context.go('/me');
}
return;
}
final leave =
_description.text.trim().isEmpty ||
await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('离开报修页面?'),
content: Text(_attempted ? '提交结果待确认,离开后请在报修工单中查看。' : '尚未提交的内容将丢失。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('继续填写'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('离开'),
),
],
),
) ==
true;
if (leave && mounted) {
setState(() => _leaving = true);
context.go(_attempted ? '/orders?tab=tickets' : '/me');
}
}
/// 用户主动点击后打开系统拨号器;无法启动时保留明确的人工拨号提示。
Future<void> _callEmergency() async {
final launcher =
widget.launchEmergency ?? (uri) => launchUrl(uri, mode: LaunchMode.externalApplication);
try {
final opened = await launcher(Uri(scheme: 'tel', path: '119'));
if (!opened) throw StateError('dialer unavailable');
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('当前设备无法打开拨号,请拨打119')),
);
}
}
}
/// 查看工单前保存当前首期草稿,避免从顶部菜单离开时丢失填写内容。
Future<void> _openRepairOrders() async {
if (_busy || _picking || _dictating) return;
final hasContent =
_description.text.trim().isNotEmpty ||
_photos.isNotEmpty ||
_address != null ||
_appointment != null;
if (hasContent && widget.draftStore != null) {
setState(() {
_busy = true;
_savingDraft = true;
_error = null;
});
try {
await _persistDraft();
} catch (_) {
if (mounted) setState(() => _error = '草稿保存失败,内容仍在当前页面,请重试。');
return;
} finally {
if (mounted) {
setState(() {
_busy = false;
_savingDraft = false;
});
}
}
}
if (!mounted) return;
setState(() => _leaving = true);
context.go('/orders?tab=tickets');
}
Future<void> _showRepairHelp() => showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('报修说明'),
content: const Text('请描述故障并上传1至3张现场照片。燃气泄漏等紧急情况请先撤离到安全区域,再拨打119。'),
actions: [
FilledButton(onPressed: () => Navigator.pop(context), child: const Text('我知道了')),
],
),
);
@override
Widget build(BuildContext context) => PopScope(
canPop: _leaving,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _back();
},
child: Scaffold(
appBar: AppBar(
title: const Text('一键报修'),
leading: BackButton(onPressed: _busy || _dictating ? null : _back),
actions: [
PopupMenuButton<String>(
tooltip: '更多',
icon: const Icon(Icons.more_horiz),
enabled: !_busy && !_dictating,
onSelected: (value) {
if (value == 'orders') _openRepairOrders();
if (value == 'help') _showRepairHelp();
},
itemBuilder: (context) => const [
PopupMenuItem(value: 'orders', child: Text('查看报修工单')),
PopupMenuItem(value: 'help', child: Text('报修说明')),
],
),
],
),
body: _initializing
? const Center(child: Text('正在读取草稿…'))
: _draftLoadFailed
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!),
TextButton(onPressed: _loadDraft, child: const Text('重试读取草稿')),
TextButton(
onPressed: () => context.go('/orders?tab=tickets'),
child: const Text('查看报修工单'),
),
if (_draftOwner != null)
TextButton(onPressed: _discardUnreadableDraft, child: const Text('清除无法读取的草稿')),
],
),
)
: ListView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
children: [
RepairStepHeader(step: _step),
const SizedBox(height: 2),
if (_step == 0) ...[
RepairSection(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 34,
height: 34,
margin: const EdgeInsets.only(right: 10),
decoration: BoxDecoration(
color: const Color(0xFFEDF3FF),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.settings_input_component_outlined,
size: 22,
color: Color(0xFF1762F4),
),
),
const Expanded(child: RequiredRepairLabel('故障类型')),
Flexible(
child: DropdownButtonFormField<String>(
initialValue: _fault,
isExpanded: true,
decoration: const InputDecoration(
isDense: true,
filled: false,
border: InputBorder.none,
enabledBorder: InputBorder.none,
contentPadding: EdgeInsets.zero,
),
icon: const Icon(Icons.chevron_right),
items: _faults.entries
.map(
(e) => DropdownMenuItem(
value: e.key,
child: Text(e.value, style: const TextStyle(fontSize: 14)),
),
)
.toList(),
onChanged: _dictating
? null
: (value) {
if (value != null) setState(() => _fault = value);
},
),
),
],
),
const Divider(height: 30, color: Color(0xFFDFE2E9)),
const RequiredRepairLabel('请描述故障现象'),
const SizedBox(height: 10),
Container(
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFDFE2E9)),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _description,
readOnly: _dictating,
minLines: 2,
maxLines: 4,
maxLength: 2000,
style: const TextStyle(fontSize: 13),
decoration: const InputDecoration(
hintText: '请详细描述故障情况,如:什么时候发生、具体现象等…',
counterText: '',
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: EdgeInsets.all(10),
),
),
RepairSpeechButton(
controller: _description,
enabled: !_busy && !_picking,
onBusy: (busy) {
if (mounted) setState(() => _dictating = busy);
},
),
],
),
),
const SizedBox(height: 10),
AbsorbPointer(
absorbing: _dictating,
child: RepairPhotos(
photos: _photos,
pickImage: widget.pickPhoto,
onChanged: () => setState(() {}),
onBusy: (value) => setState(() => _picking = value),
),
),
],
),
),
const SizedBox(height: 10),
RepairContactPreview(address: _address, onChoose: _chooseAddress),
const SizedBox(height: 10),
RepairEmergencyNotice(onCall: _callEmergency),
],
if (_step == 1) ...[
Material(
color: Colors.transparent,
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.location_on_outlined),
title: Text(_address?.address ?? '选择报修地址'),
subtitle: Text(
_address == null
? '联系与地址'
: '${_address!.contactName} ${_address!.maskedPhone}',
),
trailing: const Icon(Icons.chevron_right),
onTap: _chooseAddress,
),
),
const Divider(),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.schedule),
title: const Text('预约时间(选填)'),
subtitle: Text(_appointment?.toString().substring(0, 16) ?? '与服务人员协商'),
onTap: _chooseTime,
),
if (_appointment != null)
TextButton(
onPressed: () => setState(() => _appointment = null),
child: const Text('清除预约时间'),
),
],
if (_step == 2) ...[
_summary('故障类型', _faults[_fault]!),
_summary('故障描述', _description.text.trim()),
_summary('现场照片', '${_photos.length}张'),
_summary('联系人', '${_address!.contactName} ${_address!.maskedPhone}'),
_summary('报修地址', _address!.address),
_summary('预约时间', _appointment?.toString().substring(0, 16) ?? '与服务人员协商'),
],
if (_fault == 'leak' && _step > 0)
const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Text(
'紧急情况请先撤离至安全区域,再联系当地燃气抢险服务。',
style: TextStyle(color: Color(0xFFB45309)),
),
),
const SizedBox(height: 16),
],
),
bottomNavigationBar: _initializing || _draftLoadFailed
? null
: SafeArea(
top: false,
child: Container(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 8),
decoration: const BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Color(0xFFEAECF1))),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_error != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
_error!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 13,
),
textAlign: TextAlign.center,
),
),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _busy || _picking || _dictating ? null : _next,
child: Text(
_busy
? _savingDraft
? '暂存中…'
: '提交中…'
: _step < 2
? '下一步'
: _attempted
? '重试提交'
: '确认提交',
),
),
),
if (_attempted)
TextButton(
onPressed: _busy ? null : () => context.go('/orders?tab=tickets'),
child: const Text('查看报修工单'),
),
if (widget.draftStore != null)
TextButton(
onPressed: _busy || _picking || _dictating ? null : _saveAndExit,
child: const Text('暂存并退出'),
),
],
),
),
),
),
);
Widget _summary(String label, String value) => Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 6),
Text(value),
],
),
);
}

View File

@@ -0,0 +1,217 @@
// 功能描述:报修现场照片选择、删除与预览,保留上传结果供提交重试复用。
// 版本:1.0.0。
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'repair_layout.dart';
typedef RepairPhotoPicker = Future<XFile?> Function(ImageSource source);
class RepairPhoto {
RepairPhoto({
required this.bytes,
required this.filename,
required this.source,
required this.addedAt,
});
final Uint8List bytes;
final String filename, source;
final DateTime addedAt;
String? uri;
Map<String, Object?> toJson() => {
'uri': uri!,
'added_at': addedAt.toUtc().toIso8601String(),
'source': source,
};
}
class RepairPhotos extends StatefulWidget {
const RepairPhotos({
required this.photos,
required this.onChanged,
required this.onBusy,
this.pickImage,
super.key,
});
final List<RepairPhoto> photos;
final VoidCallback onChanged;
final ValueChanged<bool> onBusy;
final RepairPhotoPicker? pickImage;
@override
State<RepairPhotos> createState() => _RepairPhotosState();
}
class _RepairPhotosState extends State<RepairPhotos> {
bool _picking = false;
String? _error;
/// 取消系统选择不修改草稿;读取成功后才加入列表,最多三张。
Future<void> _pick() async {
if (_picking || widget.photos.length >= 3) return;
setState(() => _picking = true);
widget.onBusy(true);
try {
final source = await showModalBottomSheet<ImageSource>(
context: context,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.camera_alt_outlined),
title: const Text('拍照'),
onTap: () => Navigator.pop(context, ImageSource.camera),
),
ListTile(
leading: const Icon(Icons.photo_library_outlined),
title: const Text('从相册选择'),
onTap: () => Navigator.pop(context, ImageSource.gallery),
),
],
),
),
);
if (source == null || !mounted) return;
final file =
await (widget.pickImage ??
((source) => ImagePicker().pickImage(
source: source,
maxWidth: 2048,
maxHeight: 2048,
imageQuality: 90,
requestFullMetadata: false,
)))(source);
if (file == null || !mounted) return;
if (await file.length() > 2 * 1024 * 1024) throw Exception('每张图片不能超过2MB');
final bytes = await file.readAsBytes();
final png =
bytes.length > 8 && bytes[0] == 137 && bytes[1] == 80 && bytes[2] == 78 && bytes[3] == 71;
final jpg = bytes.length > 3 && bytes[0] == 255 && bytes[1] == 216 && bytes[2] == 255;
if (!png && !jpg) throw Exception('请选择JPG或PNG图片');
if (widget.photos.any((photo) => listEquals(photo.bytes, bytes))) throw Exception('这张照片已添加');
if (!mounted) return;
widget.photos.add(
RepairPhoto(
bytes: bytes,
filename: png ? 'repair.png' : 'repair.jpg',
source: source == ImageSource.camera ? 'camera' : 'gallery',
addedAt: DateTime.now(),
),
);
setState(() => _error = null);
widget.onChanged();
} catch (error) {
if (mounted) setState(() => _error = '无法添加照片:$error');
} finally {
if (mounted) {
setState(() => _picking = false);
widget.onBusy(false);
}
}
}
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Expanded(
child: RequiredRepairLabel('现场照片'),
),
Text('${widget.photos.length}/3'),
],
),
const SizedBox(height: 12),
GridView.count(
crossAxisCount: 3,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisSpacing: 10,
mainAxisSpacing: 10,
children: [
for (final photo in widget.photos)
Stack(
fit: StackFit.expand,
children: [
InkWell(
onTap: () => showRepairPhoto(context, photo.bytes),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.memory(
photo.bytes,
fit: BoxFit.cover,
errorBuilder: (_, e, s) => const Icon(Icons.broken_image_outlined),
),
),
),
Positioned(
top: 0,
right: 0,
child: IconButton.filledTonal(
tooltip: '删除照片',
onPressed: _picking
? null
: () {
widget.photos.remove(photo);
widget.onChanged();
},
icon: const Icon(Icons.close),
),
),
],
),
for (var slot = widget.photos.length; slot < 3; slot++)
OutlinedButton(
onPressed: _picking ? null : _pick,
style: OutlinedButton.styleFrom(
padding: EdgeInsets.zero,
foregroundColor: const Color(0xFF8AAAF4),
side: const BorderSide(color: Color(0xFFCDD3DF)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.camera_alt_outlined, size: 28),
SizedBox(height: 4),
Text('添加照片', style: TextStyle(fontSize: 12)),
],
),
),
],
),
const SizedBox(height: 8),
const Text(
'记录添加时间;原拍摄时间与位置待核实',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
if (_picking) const Text('正在选择照片…'),
if (_error != null)
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
],
);
}
/// 展示已加载字节,支持缩放,不暴露未鉴权图片链接。
Future<void> showRepairPhoto(BuildContext context, Uint8List bytes) => showDialog<void>(
context: context,
builder: (context) => Dialog(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Align(
alignment: Alignment.centerRight,
child: IconButton(
tooltip: '关闭预览',
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close),
),
),
Flexible(
child: InteractiveViewer(child: Image.memory(bytes, fit: BoxFit.contain)),
),
],
),
),
);

View File

@@ -0,0 +1,180 @@
// 功能描述:报修语音输入按钮,保护已有文字与选区,处理中阻止发单,离页终止识别。
// 版本:1.0.0。
import 'dart:async';
import 'package:flutter/material.dart';
import '../../../data/services/repair_speech.dart';
class RepairSpeechButton extends StatefulWidget {
const RepairSpeechButton({
required this.controller,
required this.onBusy,
this.speech,
this.enabled = true,
super.key,
});
final bool enabled;
final TextEditingController controller;
final ValueChanged<bool> onBusy;
final RepairSpeech? speech;
@override
State<RepairSpeechButton> createState() => _RepairSpeechButtonState();
}
/// 临时识别结果始终覆盖同一选区;用户手动修改时停止本轮,避免覆盖新输入。
class _RepairSpeechButtonState extends State<RepairSpeechButton> with WidgetsBindingObserver {
late final RepairSpeech _speech = widget.speech ?? SystemRepairSpeech.instance;
bool _active = false, _starting = false;
int _generation = 0;
String _before = '', _after = '', _lastText = '';
String? _error;
Timer? _deadline;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// 系统权限弹窗可能暂时 inactive;真正切后台仍应立即终止采集。
if (state != AppLifecycleState.resumed &&
_active &&
!(state == AppLifecycleState.inactive && _starting)) {
_finish();
unawaited(_cancel());
}
}
Future<void> _cancel() async {
try {
await _speech.cancel();
} catch (_) {
/* 离页后不再将平台错误写回界面。 */
}
}
void _finish() {
if (!mounted) return;
_deadline?.cancel();
++_generation;
setState(() {
_active = false;
_starting = false;
});
widget.onBusy(false);
}
Future<void> _toggle() async {
if (_active) {
try {
await _speech.stop();
} catch (_) {
if (mounted) setState(() => _error = '语音识别已中断,已识别文字保留。');
}
if (mounted) _finish();
await _cancel();
return;
}
final text = widget.controller.text, selection = widget.controller.selection;
final start = selection.isValid ? selection.start.clamp(0, text.length) : text.length;
final end = selection.isValid ? selection.end.clamp(start, text.length) : text.length;
_before = text.substring(0, start);
_after = text.substring(end);
_lastText = text;
if ((_before + _after).characters.length >= 2000) {
setState(() => _error = '故障描述已满2000字,请先删减。');
return;
}
final generation = ++_generation;
setState(() {
_active = true;
_starting = true;
_error = null;
});
widget.onBusy(true);
// 兜底超时也覆盖权限窗口或平台没有结束回调的情况。
_deadline = Timer(const Duration(seconds: 50), () {
if (mounted && generation == _generation) {
_finish();
unawaited(_cancel());
}
});
try {
final available = await _speech.start(
onWords: (words) {
if (!mounted || generation != _generation) return;
if (widget.controller.text != _lastText) {
_finish();
unawaited(_cancel());
return;
}
final capacity = 2000 - (_before + _after).characters.length;
final insertion = words.characters.take(capacity).toString();
_lastText = '$_before$insertion$_after';
widget.controller.value = TextEditingValue(
text: _lastText,
selection: TextSelection.collapsed(offset: (_before + insertion).length),
);
},
onError: (code) {
if (!mounted || generation != _generation) return;
setState(() => _error = _message(code));
_finish();
unawaited(_cancel());
},
onDone: () {
if (mounted && generation == _generation) _finish();
},
);
if (!mounted || generation != _generation) return;
if (!available) {
setState(() => _error = '无法使用语音识别,请检查麦克风权限或改用文字输入。');
_finish();
} else {
setState(() => _starting = false);
}
} catch (_) {
if (mounted && generation == _generation) {
setState(() => _error = '语音识别启动失败,请重试或输入文字。');
_finish();
await _cancel();
}
}
}
String _message(String code) {
if (code.contains('permission') || code.contains('not_allowed')) {
return '未获得麦克风或语音识别权限,请在系统设置中开启,或输入文字。';
}
if (code.contains('no_match') || code.contains('speech_timeout')) return '没有识别到语音,请重试。';
if (code.contains('network')) return '语音服务连接失败,已识别文字保留,请重试。';
return '语音识别已中断,已识别文字保留,请重试。';
}
@override
void dispose() {
_deadline?.cancel();
++_generation;
WidgetsBinding.instance.removeObserver(this);
if (_active) unawaited(_cancel());
super.dispose();
}
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
TextButton.icon(
onPressed: widget.enabled ? _toggle : null,
icon: Icon(_active ? Icons.stop_circle_outlined : Icons.mic_none, size: 22),
label: Text(_active ? (_starting ? '取消启动' : '结束识别') : '语音输入'),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
),
),
if (_error != null)
Text(_error!, style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.error)),
],
);
}

View File

@@ -0,0 +1,215 @@
// 功能描述:展示本人报修详情、实际处理记录,以及确认后执行的取消和完成动作。
// 版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../data/repositories/client_repository.dart';
import '../../../data/services/api_client.dart';
import '../../../domain/models/service_ticket.dart';
import '../../core/async_content.dart';
import 'ticket_photos.dart';
class TicketDetailPage extends StatefulWidget {
const TicketDetailPage({required this.repository, required this.identity, super.key});
final ClientRepository repository;
final String identity;
@override
State<TicketDetailPage> createState() => _TicketDetailPageState();
}
class _TicketDetailPageState extends State<TicketDetailPage> {
final _content = GlobalKey<AsyncContentState<ServiceTicket>>();
bool _busy = false;
bool _sending = false;
/// 用户确认后才调用动作;响应失败保留详情,重试由服务端保证幂等。
Future<void> _act(String action) async {
if (_busy) return;
setState(() => _busy = true);
final cancelling = action == 'cancel';
try {
final accepted = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(cancelling ? '取消这张工单?' : '确认问题已处理完成?'),
content: Text(cancelling ? '取消后本次服务申请将终止。' : '请确认处理结果与现场情况一致,完成后工单将关闭。'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('返回')),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(cancelling ? '确认取消' : '确认完成'),
),
],
),
);
if (accepted != true || !mounted) return;
setState(() => _sending = true);
if (cancelling) {
await widget.repository.cancelTicket(widget.identity);
} else {
await widget.repository.confirmTicket(widget.identity);
}
if (!mounted) return;
await _content.currentState?.refresh();
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(cancelling ? '工单已取消' : '工单已完成')));
}
} catch (error) {
if (mounted && error is! SessionExpiredException) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error.toString())));
}
} finally {
if (mounted) {
setState(() {
_busy = false;
_sending = false;
});
}
}
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: const Text('报修工单详情'),
leading: BackButton(
onPressed: () => context.canPop() ? context.pop() : context.go('/orders?tab=tickets'),
),
actions: [
IconButton(
tooltip: '刷新工单',
onPressed: _busy ? null : () => _content.currentState?.refresh(),
icon: const Icon(Icons.refresh),
),
],
),
body: AsyncContent<ServiceTicket>(
key: _content,
load: () async => ServiceTicket(await widget.repository.ticket(widget.identity)),
builder: (context, ticket) => ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
children: [
_section(
children: [
Row(
children: [
const Icon(Icons.build_circle, size: 42, color: Color(0xFF2563EB)),
const SizedBox(width: 12),
Expanded(
child: Text(
ticket.state,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(color: const Color(0xFF2563EB)),
),
),
],
),
const SizedBox(height: 12),
SelectableText('工单号:${ticket.number}'),
],
),
_section(
title: '故障信息',
children: [
_field(Icons.build_outlined, '故障类型', ticket.fault),
_field(Icons.chat_bubble_outline, '故障描述', ticket.description),
_field(Icons.schedule, '提交时间', ticket.time('created_at')),
_field(Icons.location_on_outlined, '报修地址', ticket.address),
if (ticket.text('contact_name').isNotEmpty)
_field(Icons.person_outline, '联系人', ticket.text('contact_name')),
if (ticket.text('contact_phone').isNotEmpty)
_field(Icons.phone_outlined, '联系电话', ticket.text('contact_phone')),
],
),
_section(
title: '预约信息',
children: [
_field(
Icons.schedule,
'预约时间',
ticket.text('appointment_at').isEmpty ? '未预约' : ticket.time('appointment_at'),
),
],
),
if (ticket.photos.isNotEmpty)
_section(
children: [
TicketPhotos(
repository: widget.repository,
ticketIdentity: ticket.identity,
photos: ticket.photos,
),
],
),
_section(
title: '处理记录',
children: [
_field(Icons.check_circle_outline, '已提交', ticket.time('created_at')),
if (ticket.text('started_at').isNotEmpty)
_field(Icons.engineering_outlined, '开始处理', ticket.time('started_at')),
if (ticket.result.isNotEmpty)
_field(Icons.assignment_turned_in_outlined, '处理结果', ticket.result),
if (ticket.result.isEmpty)
const Padding(padding: EdgeInsets.symmetric(vertical: 8), child: Text('暂无处理结果')),
if (ticket.text('completed_at').isNotEmpty)
_field(Icons.check_circle, '确认完成', ticket.time('completed_at')),
],
),
if (_sending) const LinearProgressIndicator(),
if (ticket.actions.contains('confirm')) ...[
FilledButton(
onPressed: _busy ? null : () => _act('confirm'),
child: const Text('确认处理完成'),
),
const SizedBox(height: 12),
],
if (ticket.actions.contains('cancel'))
OutlinedButton(
onPressed: _busy ? null : () => _act('cancel'),
child: const Text('取消工单'),
),
],
),
),
);
/// 按设计稿的信息分组展示;窄屏和长地址自然换行。
Widget _section({String? title, required List<Widget> children}) => Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFE0E5EE)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title != null) ...[
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
],
...children,
],
),
);
Widget _field(IconData icon, String label, String value) => Padding(
padding: const EdgeInsets.symmetric(vertical: 9),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: const Color(0xFF2563EB), size: 20),
const SizedBox(width: 9),
SizedBox(width: 72, child: Text(label, style: Theme.of(context).textTheme.bodyMedium)),
const SizedBox(width: 8),
Expanded(
child: SelectableText(value, textAlign: TextAlign.right),
),
],
),
);
}

View File

@@ -0,0 +1,107 @@
// 功能描述:读取本人工单照片并展示可重试缩略图,文件不使用公开URL。
// 版本:1.0.0。
import 'dart:typed_data';
import 'package:flutter/material.dart';
import '../../../data/repositories/client_repository.dart';
import 'repair_photos.dart';
class TicketPhotos extends StatelessWidget {
const TicketPhotos({
required this.repository,
required this.ticketIdentity,
required this.photos,
super.key,
});
final ClientRepository repository;
final String ticketIdentity;
final List<Map<String, Object?>> photos;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('现场照片(${photos.length}张)', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
GridView.count(
crossAxisCount: 3,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 10,
crossAxisSpacing: 10,
children: [
for (final photo in photos)
_Photo(
repository: repository,
ticketIdentity: ticketIdentity,
identity: photo['identity'] as String,
),
],
),
const SizedBox(height: 8),
for (var index = 0; index < photos.length; index++)
Text(
'照片${index + 1}添加时间:${DateTime.tryParse(photos[index]['added_at'] as String? ?? '')?.toLocal().toString().substring(0, 16) ?? '未记录'}',
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
const Text('拍摄时间与位置未核实', style: TextStyle(fontSize: 12, color: Colors.grey)),
],
);
}
class _Photo extends StatefulWidget {
const _Photo({required this.repository, required this.ticketIdentity, required this.identity});
final ClientRepository repository;
final String ticketIdentity, identity;
@override
State<_Photo> createState() => _PhotoState();
}
class _PhotoState extends State<_Photo> {
late Future<Uint8List?> _image;
@override
void initState() {
super.initState();
_load();
}
void _load() {
_image = widget.repository.ticketPhoto(widget.ticketIdentity, widget.identity);
}
@override
void didUpdateWidget(covariant _Photo oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.identity != widget.identity ||
oldWidget.ticketIdentity != widget.ticketIdentity) {
_load();
}
}
@override
Widget build(BuildContext context) => FutureBuilder<Uint8List?>(
future: _image,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (!snapshot.hasData || snapshot.hasError) {
return IconButton(
tooltip: '重新加载照片',
onPressed: () => setState(_load),
icon: const Icon(Icons.broken_image_outlined),
);
}
return InkWell(
onTap: () => showRepairPhoto(context, snapshot.data!),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.memory(
snapshot.data!,
fit: BoxFit.cover,
semanticLabel: '查看现场照片',
errorBuilder: (_, e, s) => const Icon(Icons.broken_image_outlined),
),
),
);
},
);
}