已完成用户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,232 @@
// 功能描述:收货地址新增和编辑,保存失败保留输入,删除前确认。
// 版本:1.0.0。
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import '../../../data/repositories/client_repository.dart';
import '../../../data/services/api_client.dart';
import '../../../domain/models/shipping_address.dart';
/// 以已有地址预填;返回 true 表示服务端已保存或删除。
class AddressEditPage extends StatefulWidget {
const AddressEditPage({required this.repository, this.address, super.key});
final ClientRepository repository;
final ShippingAddress? address;
@override
State<AddressEditPage> createState() => _AddressEditPageState();
}
class _AddressEditPageState extends State<AddressEditPage> {
final _form = GlobalKey<FormState>();
final _requestNo = const Uuid().v7();
late final _name = TextEditingController(text: widget.address?.contactName);
late final _phone = TextEditingController(text: widget.address?.contactPhone);
late final _address = TextEditingController(text: widget.address?.address);
late final _longitude = TextEditingController(text: widget.address?.longitude);
late final _latitude = TextEditingController(text: widget.address?.latitude);
late bool _default = widget.address?.isDefault ?? false;
bool _busy = false, _dirty = false;
String? _error;
@override
void dispose() {
for (final controller in [_name, _phone, _address, _longitude, _latitude]) {
controller.dispose();
}
super.dispose();
}
/// 只有成功响应才退出;网络异常保留表单与同一个新增请求号。
Future<void> _save() async {
if (_busy || !_form.currentState!.validate()) return;
await _mutate(
() => widget.repository.saveShippingAddress(
ShippingAddress(
identity: widget.address?.identity ?? '',
address: _address.text.trim(),
contactName: _name.text.trim(),
contactPhone: _phone.text.trim(),
longitude: _longitude.text.trim(),
latitude: _latitude.text.trim(),
isDefault: _default,
),
requestNo: _requestNo,
),
);
}
Future<void> _mutate(Future<void> Function() action) async {
setState(() {
_busy = true;
_error = null;
});
try {
await action();
if (!mounted) return;
setState(() {
_dirty = false;
_busy = false;
});
// 等待 PopScope 更新后再返回,避免将保存成功当作放弃编辑。
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) Navigator.pop(context, true);
});
} on SessionExpiredException {
if (mounted) setState(() => _busy = false);
} catch (error) {
if (mounted) {
setState(() {
_busy = false;
_error = error is ApiException ? error.message : '操作失败,请重试';
});
}
}
}
Future<void> _delete() 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) {
await _mutate(() => widget.repository.deleteAddress(widget.address!.identity));
}
}
/// 未保存退出需确认;保存中禁止重复提交与退出。
Future<void> _back() async {
if (_busy) return;
if (!_dirty) {
Navigator.pop(context);
return;
}
final discard = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('放弃未保存的修改?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('继续编辑')),
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('放弃')),
],
),
);
if (discard == true && mounted) {
setState(() => _dirty = false);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) Navigator.pop(context);
});
}
}
String? _coordinate(String? value, double maximum, String other) {
final text = value?.trim() ?? '';
if (text.isEmpty && other.trim().isEmpty) return null;
final number = double.tryParse(text);
return number == null || !number.isFinite || number.abs() > maximum ? '请填写有效坐标' : null;
}
@override
Widget build(BuildContext context) => PopScope(
canPop: !_busy && !_dirty,
onPopInvokedWithResult: (didPop, result) {
if (!didPop) _back();
},
child: Scaffold(
appBar: AppBar(title: Text(widget.address == null ? '新增收货地址' : '编辑收货地址')),
body: Form(
key: _form,
onChanged: () {
if (!_dirty) setState(() => _dirty = true);
},
child: ListView(
padding: const EdgeInsets.all(20),
children: [
if (_error != null)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
),
TextFormField(
controller: _name,
enabled: !_busy,
maxLength: 64,
decoration: const InputDecoration(labelText: '联系人'),
validator: (v) => v == null || v.trim().isEmpty ? '请填写联系人' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _phone,
enabled: !_busy,
keyboardType: TextInputType.phone,
maxLength: 11,
decoration: const InputDecoration(labelText: '联系电话'),
validator: (v) =>
RegExp(r'^1[3-9]\d{9}$').hasMatch(v?.trim() ?? '') ? null : '请填写正确的手机号',
),
const SizedBox(height: 12),
TextFormField(
controller: _address,
enabled: !_busy,
minLines: 2,
maxLines: 4,
maxLength: 255,
decoration: const InputDecoration(labelText: '详细地址', hintText: '省市区、街道及门牌号'),
validator: (v) => v == null || v.trim().isEmpty ? '请填写详细地址' : null,
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('设为默认地址'),
value: _default,
onChanged: _busy
? null
: (v) => setState(() {
_default = v;
_dirty = true;
}),
),
ExpansionTile(
title: const Text('地址坐标'),
tilePadding: EdgeInsets.zero,
children: [
TextFormField(
controller: _longitude,
enabled: !_busy,
decoration: const InputDecoration(labelText: '经度'),
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
validator: (v) => _coordinate(v, 180, _latitude.text),
),
const SizedBox(height: 12),
TextFormField(
controller: _latitude,
enabled: !_busy,
decoration: const InputDecoration(labelText: '纬度'),
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
validator: (v) => _coordinate(v, 90, _longitude.text),
),
],
),
if (widget.address != null)
TextButton(
onPressed: _busy ? null : _delete,
style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.error),
child: const Text('删除地址'),
),
],
),
),
bottomNavigationBar: SafeArea(
minimum: const EdgeInsets.fromLTRB(20, 10, 20, 16),
child: FilledButton(
onPressed: _busy ? null : _save,
child: Text(_busy ? '正在保存…' : '保存地址'),
),
),
),
);
}

View File

@@ -0,0 +1,240 @@
// 功能描述:按 29 号设计组织地址卡片与服务说明,支持管理及下单选择。
// 版本: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/shipping_address.dart';
import '../../core/async_content.dart';
import 'address_edit_page.dart';
class AddressesPage extends StatefulWidget {
const AddressesPage({required this.repository, this.selecting = false, super.key});
final ClientRepository repository;
final bool selecting;
@override
State<AddressesPage> createState() => _AddressesPageState();
}
/// 每次变更成功重新读库;默认切换失败不改变界面事实。
class _AddressesPageState extends State<AddressesPage> {
final _contentKey = GlobalKey<AsyncContentState<List<ShippingAddress>>>();
bool _busy = false;
Future<void> _edit([ShippingAddress? address]) async {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
final changed = await Navigator.push<bool>(
context,
MaterialPageRoute(
builder: (_) => AddressEditPage(repository: widget.repository, address: address),
),
);
if (mounted && changed == true) await _contentKey.currentState?.refresh();
}
Future<void> _setDefault(ShippingAddress address) async {
if (_busy || address.isDefault) return;
setState(() => _busy = true);
try {
await widget.repository.setDefaultAddress(address.identity);
if (mounted) await _contentKey.currentState?.refresh();
} on SessionExpiredException {
return;
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error is ApiException ? error.message : '默认地址设置失败,请重试')),
);
}
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
leading: BackButton(
onPressed: () {
if (Navigator.canPop(context)) {
Navigator.pop(context);
} else {
context.go('/me');
}
},
),
title: Text(widget.selecting ? '选择收货地址' : '地址管理'),
actions: [
TextButton(onPressed: _busy ? null : () => _edit(), child: const Text('添加')),
],
),
body: AsyncContent<List<ShippingAddress>>(
key: _contentKey,
load: widget.repository.shippingAddresses,
builder: (context, addresses) => ListView(
padding: const EdgeInsets.fromLTRB(18, 16, 18, 24),
physics: const AlwaysScrollableScrollPhysics(),
children: [
if (addresses.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 48),
child: Center(child: Text('暂无收货地址')),
),
for (final address in addresses) _card(address),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFE5E7EB)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.info, size: 26, color: Color(0xFF2563EB)),
SizedBox(width: 8),
Text('服务说明', style: TextStyle(fontWeight: FontWeight.w600)),
],
),
SizedBox(height: 8),
Text(
'地址用于气瓶配送、上门回收及维修服务,请填写真实门牌信息。',
style: TextStyle(fontSize: 13, color: Color(0xFF6B7280), height: 1.5),
),
],
),
),
],
),
),
bottomNavigationBar: SafeArea(
minimum: const EdgeInsets.fromLTRB(18, 10, 18, 16),
child: FilledButton.icon(
onPressed: _busy ? null : () => _edit(),
icon: const Icon(Icons.add_circle_outline),
label: const Text('新增收货地址'),
),
),
);
Widget _card(ShippingAddress address) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Material(
color: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: const BorderSide(color: Color(0xFFE5E7EB)),
),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
InkWell(
onTap: _busy
? null
: () {
if (widget.selecting) {
if (address.contactName.isEmpty || address.contactPhone.isEmpty) {
_edit(address);
return;
}
Navigator.pop(context, address);
} else {
_edit(address);
}
},
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 18, 10, 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.location_on_outlined, color: Color(0xFF2563EB), size: 22),
const SizedBox(width: 20),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 10,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
address.contactName.isEmpty ? '待补充联系人' : address.contactName,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
Text(
address.maskedPhone,
style: const TextStyle(fontSize: 13, color: Color(0xFF6B7280)),
),
if (address.isDefault)
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
border: Border.all(color: const Color(0xFFBFDBFE)),
borderRadius: BorderRadius.circular(3),
),
child: const Text(
'默认',
style: TextStyle(color: Color(0xFF2563EB), fontSize: 12),
),
),
],
),
const SizedBox(height: 8),
Text(address.address, style: const TextStyle(fontSize: 14, height: 1.5)),
const SizedBox(height: 10),
const Text(
'配送范围待气站确认',
style: TextStyle(fontSize: 12, color: Color(0xFF9A6700)),
),
],
),
),
const Icon(Icons.chevron_right, color: Color(0xFF9CA3AF), size: 20),
],
),
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
Expanded(
child: TextButton.icon(
style: TextButton.styleFrom(
textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 14),
disabledForegroundColor: const Color(0xFF2563EB),
),
onPressed: _busy || address.isDefault ? null : () => _setDefault(address),
icon: Icon(
Icons.check_circle_outline,
size: 18,
),
label: const Text('设为默认'),
),
),
Container(height: 18, width: 1, color: const Color(0xFFE5E7EB)),
Expanded(
child: TextButton.icon(
style: TextButton.styleFrom(
textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 14),
foregroundColor: const Color(0xFF111827),
),
onPressed: _busy ? null : () => _edit(address),
icon: const Icon(Icons.edit_outlined, size: 20, color: Color(0xFF2563EB)),
label: const Text('编辑'),
),
),
],
),
),
],
),
),
);
}