feat: add Flutter mobile clients and staff delivery API

This commit is contained in:
david
2026-07-30 21:47:41 +08:00
parent 550efb3812
commit 36a5ced1c0
228 changed files with 17159 additions and 22 deletions

View File

@@ -0,0 +1,119 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../app/dependencies.dart';
class LoginPage extends StatefulWidget {
const LoginPage({required this.session, super.key});
final UserSession session;
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final _phone = TextEditingController();
final _password = TextEditingController();
bool _submitting = false;
String? _error;
@override
void dispose() {
_phone.dispose();
_password.dispose();
super.dispose();
}
Future<void> _login() async {
setState(() {
_submitting = true;
_error = null;
});
try {
await widget.session.login(phone: _phone.text.trim(), password: _password.text);
} catch (error) {
if (mounted) setState(() => _error = error.toString());
} finally {
if (mounted) setState(() => _submitting = false);
}
}
@override
Widget build(BuildContext context) => Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Icon(
Icons.health_and_safety_rounded,
size: 68,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 20),
Text(
'瓶安芯',
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.headlineLarge?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 8),
const Text('安全服务与生活采购', textAlign: TextAlign.center),
const SizedBox(height: 36),
TextField(
controller: _phone,
keyboardType: TextInputType.phone,
autofillHints: const [AutofillHints.telephoneNumber],
decoration: const InputDecoration(
labelText: '手机号',
prefixIcon: Icon(Icons.phone_outlined),
),
),
const SizedBox(height: 14),
TextField(
controller: _password,
obscureText: true,
autofillHints: const [AutofillHints.password],
decoration: const InputDecoration(
labelText: '密码',
prefixIcon: Icon(Icons.lock_outline),
),
),
if (_error != null) ...[
const SizedBox(height: 12),
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
],
const SizedBox(height: 20),
ElevatedButton(
onPressed: _submitting ? null : _login,
child: _submitting
? const SizedBox.square(
dimension: 22,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('安全登录'),
),
TextButton(
onPressed: () => context.push('/register'),
child: const Text('首次使用?注册账号'),
),
const SizedBox(height: 12),
const Text(
'登录即表示同意用户协议和隐私政策',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12),
),
],
),
),
),
),
),
);
}

View File

@@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
import '../../../app/dependencies.dart';
import '../../../data/services/api_client.dart';
class RegisterPage extends StatefulWidget {
const RegisterPage({required this.session, super.key});
final UserSession session;
@override
State<RegisterPage> createState() => _RegisterPageState();
}
class _RegisterPageState extends State<RegisterPage> {
final _phone = TextEditingController();
final _name = TextEditingController();
final _address = TextEditingController();
final _password = TextEditingController();
final _code = TextEditingController();
String _requestIdentity = '';
bool _busy = false;
String? _message;
Future<void> _sendCode() async {
try {
final identity = await widget.session.sendCode(_phone.text.trim(), 'register');
setState(() {
_requestIdentity = identity;
_message = '验证码已发送';
});
} catch (error) {
setState(() => _message = error.toString());
}
}
Future<void> _register() async {
setState(() => _busy = true);
try {
final api = ApiClient(() => '');
await api.post(
'/heqi/client/v1/user/auth/register',
authenticated: false,
body: {
'phone': _phone.text.trim(),
'name': _name.text.trim(),
'address': _address.text.trim(),
'password': _password.text,
'code': _code.text.trim(),
'request_identity': _requestIdentity,
},
);
if (mounted) Navigator.of(context).pop();
} catch (error) {
if (mounted) setState(() => _message = error.toString());
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
void dispose() {
_phone.dispose();
_name.dispose();
_address.dispose();
_password.dispose();
_code.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('注册用户')),
body: ListView(
padding: const EdgeInsets.all(20),
children: [
TextField(
controller: _phone,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(labelText: '手机号'),
),
const SizedBox(height: 12),
TextField(
controller: _name,
decoration: const InputDecoration(labelText: '姓名'),
),
const SizedBox(height: 12),
TextField(
controller: _address,
decoration: const InputDecoration(labelText: '服务地址'),
),
const SizedBox(height: 12),
TextField(
controller: _password,
obscureText: true,
decoration: const InputDecoration(labelText: '登录密码'),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextField(
controller: _code,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: '验证码'),
),
),
const SizedBox(width: 10),
OutlinedButton(onPressed: _sendCode, child: const Text('获取验证码')),
],
),
if (_message != null)
Padding(padding: const EdgeInsets.only(top: 12), child: Text(_message!)),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _busy || _requestIdentity.isEmpty ? null : _register,
child: const Text('创建账号'),
),
],
),
);
}

View File

@@ -0,0 +1,109 @@
import 'package:flutter/material.dart';
import '../../../data/repositories/client_repository.dart';
import '../../../domain/models/client_models.dart';
import '../../core/widgets.dart';
class HomePage extends StatefulWidget {
const HomePage({required this.repository, super.key});
final ClientRepository repository;
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
late Future<(List<ClientRecord>, Map<String, Object?>?)> _future;
@override
void initState() {
super.initState();
_future = _load();
}
Future<(List<ClientRecord>, Map<String, Object?>?)> _load() async =>
(await widget.repository.contents(), await widget.repository.serviceRelation());
Future<void> _refresh() async {
setState(() => _future = _load());
await _future;
}
@override
Widget build(BuildContext context) => Scaffold(
body: SafeArea(
child: FutureBuilder<(List<ClientRecord>, Map<String, Object?>?)>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return EmptyState(
title: '首页加载失败',
description: snapshot.error.toString(),
onRetry: _refresh,
);
}
final (contents, relation) = snapshot.data ?? (const <ClientRecord>[], null);
return RefreshIndicator(
onRefresh: _refresh,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 24),
children: [
const PageIntro(
eyebrow: '安全生活',
title: '今天也要安心用气',
description: '设备控制能力尚未开放,本页只展示真实服务与安全内容。',
),
Card(
margin: const EdgeInsets.symmetric(horizontal: 16),
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
Icon(
Icons.store_mall_directory_outlined,
size: 38,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('当前服务归属', style: TextStyle(fontWeight: FontWeight.w800)),
const SizedBox(height: 4),
Text(
relation == null
? '尚未建立服务关系'
: '${relation['gas_name'] ?? ''} ${relation['delivery_name'] ?? ''}',
),
],
),
),
],
),
),
),
const Padding(
padding: EdgeInsets.fromLTRB(20, 26, 20, 8),
child: Text('安全公告', style: TextStyle(fontWeight: FontWeight.w800, fontSize: 18)),
),
if (contents.isEmpty)
const Padding(
padding: EdgeInsets.all(20),
child: Text('暂无已发布内容'),
)
else
...contents.take(6).map((item) => RecordCard(record: item)),
],
),
);
},
),
),
);
}

View File

@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import '../../../data/repositories/client_repository.dart';
import '../shared/record_list_page.dart';
import '../shared/record_list_view_model.dart';
class OrdersPage extends StatelessWidget {
const OrdersPage({required this.repository, super.key});
final ClientRepository repository;
@override
Widget build(BuildContext context) => DefaultTabController(
length: 3,
child: Scaffold(
appBar: AppBar(
title: const Text('我的订单'),
bottom: const TabBar(
tabs: [
Tab(text: '商城'),
Tab(text: '供气'),
Tab(text: '服务工单'),
],
),
),
body: TabBarView(
children: [
RecordListPage(
title: '商城订单',
eyebrow: '交易',
viewModel: RecordListViewModel(repository.shopOrders),
),
RecordListPage(
title: '供气订单',
eyebrow: '履约',
viewModel: RecordListViewModel(repository.gasOrders),
),
RecordListPage(
title: '服务工单',
eyebrow: '服务',
viewModel: RecordListViewModel(repository.tickets),
),
],
),
),
);
}

View File

@@ -0,0 +1,175 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:uuid/uuid.dart';
import '../../../app/dependencies.dart';
import '../../../data/repositories/client_repository.dart';
import '../../../domain/models/client_models.dart';
class ProfilePage extends StatefulWidget {
const ProfilePage({required this.session, required this.repository, super.key});
final UserSession session;
final ClientRepository repository;
@override
State<ProfilePage> createState() => _ProfilePageState();
}
class _ProfilePageState extends State<ProfilePage> {
late Future<(UserProfile, WalletSummary)> _future;
@override
void initState() {
super.initState();
_future = _load();
}
Future<(UserProfile, WalletSummary)> _load() async =>
(await widget.repository.profile(), await widget.repository.wallet());
Future<void> _addAddress() async {
final controller = TextEditingController();
final address = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('新增地址'),
content: TextField(
controller: controller,
decoration: const InputDecoration(labelText: '详细地址'),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('取消')),
FilledButton(
onPressed: () => Navigator.pop(context, controller.text.trim()),
child: const Text('保存'),
),
],
),
);
controller.dispose();
if (address == null || address.isEmpty) return;
await widget.repository.addAddress(address, isDefault: true);
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('地址已保存')));
}
Future<void> _createTicket() async {
final controller = TextEditingController();
final description = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('申请维修'),
content: TextField(
controller: controller,
maxLines: 4,
decoration: const InputDecoration(labelText: '问题描述'),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('取消')),
FilledButton(
onPressed: () => Navigator.pop(context, controller.text.trim()),
child: const Text('提交'),
),
],
),
);
controller.dispose();
if (description == null || description.isEmpty) return;
await widget.repository.createTicket(
requestNo: const Uuid().v7(),
category: 'repair',
description: description,
);
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('工单已提交')));
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('我的')),
body: FutureBuilder<(UserProfile, WalletSummary)>(
future: _future,
builder: (context, snapshot) {
if (!snapshot.hasData) {
if (snapshot.hasError) return Center(child: Text(snapshot.error.toString()));
return const Center(child: CircularProgressIndicator());
}
final (profile, wallet) = snapshot.data!;
return ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
CircleAvatar(
radius: 30,
child: Text(profile.name.isEmpty ? '' : profile.name.substring(0, 1)),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
profile.name,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800),
),
Text(profile.phone),
],
),
),
],
),
),
),
Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('钱包余额'),
SizedBox(height: 4),
Text('充值结果以服务端确认为准', style: TextStyle(fontSize: 12)),
],
),
Text(
moneyText(wallet.balance),
style: Theme.of(
context,
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w900),
),
],
),
),
),
_item(Icons.location_on_outlined, '地址管理', _addAddress),
_item(Icons.description_outlined, '供气合同', () => context.push('/records/contracts')),
_item(
Icons.account_balance_wallet_outlined,
'钱包流水',
() => context.push('/records/wallet'),
),
_item(Icons.build_outlined, '申请维修', _createTicket),
_item(Icons.logout, '退出登录', widget.session.logout),
],
);
},
),
);
Widget _item(IconData icon, String title, VoidCallback onTap) => Card(
child: ListTile(
leading: Icon(icon),
title: Text(title),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
),
);
}

View File

@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import '../../core/widgets.dart';
import 'record_list_view_model.dart';
class RecordListPage extends StatefulWidget {
const RecordListPage({
required this.title,
required this.eyebrow,
required this.viewModel,
this.description,
this.floatingActionButton,
super.key,
});
final String title;
final String eyebrow;
final String? description;
final RecordListViewModel viewModel;
final Widget? floatingActionButton;
@override
State<RecordListPage> createState() => _RecordListPageState();
}
class _RecordListPageState extends State<RecordListPage> {
@override
void initState() {
super.initState();
widget.viewModel.load();
}
@override
void dispose() {
widget.viewModel.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: Text(widget.title)),
floatingActionButton: widget.floatingActionButton,
body: ListenableBuilder(
listenable: widget.viewModel,
builder: (context, _) {
final state = widget.viewModel;
if (state.loading && state.records.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (state.error != null && state.records.isEmpty) {
return EmptyState(
title: '加载失败',
description: state.error.toString(),
onRetry: state.load,
);
}
return RefreshIndicator(
onRefresh: state.load,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: PageIntro(
eyebrow: widget.eyebrow,
title: widget.title,
description: widget.description,
),
),
if (state.records.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: EmptyState(title: '暂无记录', description: '服务端还没有可展示的数据'),
)
else
SliverList.builder(
itemCount: state.records.length,
itemBuilder: (context, index) => RecordCard(record: state.records[index]),
),
const SliverPadding(padding: EdgeInsets.only(bottom: 24)),
],
),
);
},
),
);
}

View File

@@ -0,0 +1,32 @@
import 'package:flutter/foundation.dart';
import '../../../domain/models/client_models.dart';
typedef RecordLoader = Future<List<ClientRecord>> Function();
class RecordListViewModel extends ChangeNotifier {
RecordListViewModel(this._loader);
final RecordLoader _loader;
List<ClientRecord> _records = const [];
Object? _error;
bool _loading = false;
List<ClientRecord> get records => List.unmodifiable(_records);
Object? get error => _error;
bool get loading => _loading;
Future<void> load() async {
_loading = true;
_error = null;
notifyListeners();
try {
_records = await _loader();
} catch (error) {
_error = error;
} finally {
_loading = false;
notifyListeners();
}
}
}

View File

@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import '../../../data/repositories/client_repository.dart';
import '../../../domain/models/client_models.dart';
import '../../core/widgets.dart';
class ShopPage extends StatefulWidget {
const ShopPage({required this.repository, super.key});
final ClientRepository repository;
@override
State<ShopPage> createState() => _ShopPageState();
}
class _ShopPageState extends State<ShopPage> {
late Future<List<ClientRecord>> _future;
@override
void initState() {
super.initState();
_future = widget.repository.products();
}
Future<void> _buy(ClientRecord product) async {
final profile = await widget.repository.profile();
final addresses = await widget.repository.addresses();
if (!mounted) return;
if (addresses.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请先在“我的”中添加收货地址')));
return;
}
final confirmed = await showModalBottomSheet<bool>(
context: context,
showDragHandle: true,
builder: (context) => Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 30),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'确认下单',
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800),
),
const SizedBox(height: 12),
Text(product.title),
Text(addresses.first.title),
const SizedBox(height: 18),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('提交订单'),
),
],
),
),
);
if (confirmed != true) return;
try {
await widget.repository.createShopOrder(
requestNo: const Uuid().v7(),
productIdentity: product.identity,
addressIdentity: addresses.first.identity,
contactName: profile.name,
contactPhone: profile.phone,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('订单已创建,请前往订单页支付')));
}
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error.toString())));
}
}
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('安全商城')),
body: FutureBuilder<List<ClientRecord>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return EmptyState(title: '商品加载失败', description: snapshot.error.toString());
}
final products = snapshot.data ?? const [];
return ListView(
padding: const EdgeInsets.only(bottom: 24),
children: [
const PageIntro(eyebrow: '品质保障', title: '燃气安全商城', description: '价格和库存以服务端结算为准'),
if (products.isEmpty)
const EmptyState(title: '暂无商品', description: '目前没有上架且有库存的商品')
else
...products.map(
(product) => Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 7),
child: Padding(
padding: const EdgeInsets.all(18),
child: Row(
children: [
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: const Color(0xFFEAF1FF),
borderRadius: BorderRadius.circular(16),
),
child: const Icon(Icons.local_fire_department_outlined),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.title,
style: const TextStyle(fontWeight: FontWeight.w800),
),
const SizedBox(height: 6),
Text(product.subtitle),
],
),
),
IconButton.filled(
onPressed: () => _buy(product),
icon: const Icon(Icons.add_shopping_cart),
),
],
),
),
),
),
],
);
},
),
);
}