已完成用户APP首期功能开发
交付用户端首期页面、配套接口、后台资源及测试文档。用户APP构建、静态分析和三个管理后台构建通过;完整测试仍有2项失败,后端模型注释检查未通过,详见交付记录。
This commit is contained in:
232
apps/user_app/lib/ui/features/address/address_edit_page.dart
Normal file
232
apps/user_app/lib/ui/features/address/address_edit_page.dart
Normal 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 ? '正在保存…' : '保存地址'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
240
apps/user_app/lib/ui/features/address/addresses_page.dart
Normal file
240
apps/user_app/lib/ui/features/address/addresses_page.dart
Normal 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('编辑'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user