已完成用户APP首期功能开发
交付用户端首期页面、配套接口、后台资源及测试文档。用户APP构建、静态分析和三个管理后台构建通过;完整测试仍有2项失败,后端模型注释检查未通过,详见交付记录。
This commit is contained in:
226
apps/user_app/lib/ui/features/tickets/repair_layout.dart
Normal file
226
apps/user_app/lib/ui/features/tickets/repair_layout.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
788
apps/user_app/lib/ui/features/tickets/repair_page.dart
Normal file
788
apps/user_app/lib/ui/features/tickets/repair_page.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
217
apps/user_app/lib/ui/features/tickets/repair_photos.dart
Normal file
217
apps/user_app/lib/ui/features/tickets/repair_photos.dart
Normal 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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
180
apps/user_app/lib/ui/features/tickets/repair_speech_button.dart
Normal file
180
apps/user_app/lib/ui/features/tickets/repair_speech_button.dart
Normal 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)),
|
||||
],
|
||||
);
|
||||
}
|
||||
215
apps/user_app/lib/ui/features/tickets/ticket_detail_page.dart
Normal file
215
apps/user_app/lib/ui/features/tickets/ticket_detail_page.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
107
apps/user_app/lib/ui/features/tickets/ticket_photos.dart
Normal file
107
apps/user_app/lib/ui/features/tickets/ticket_photos.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user