feat: 完成 Flutter 版智能电网 IoT 管理应用初始化搭建

This commit is contained in:
andyesfly committed 2026-10-02 17:21:53 +08:00
commit f61b7bf90b
120 files changed
+15229

No files matched your search

+105
View File
@@ -0,0 +1,105 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/api_client.dart';
import '../../../core/network/api_response.dart';
import '../../../core/providers.dart';
import 'visit_models.dart';
final visitApiProvider = Provider<VisitApi>(
(ref) => VisitApi(ref.watch(apiClientProvider)),
);
/// 拜访台账接口,对应 uni-app 版 `api/customerVisit.ts`。
class VisitApi {
VisitApi(this._client);
final ApiClient _client;
Future<ApiResponse<VisitPage>> list({
int page = 1,
int pageSize = 100,
String? keyword,
int? customerId,
int? salesmanId,
int? visitType,
int? status,
int? priority,
String? planStartDate,
String? planEndDate,
}) => _client.get<VisitPage>(
'/customer-visits',
query: {
'page': page,
'pageSize': pageSize,
'keyword': keyword,
'customerId': customerId,
'salesmanId': salesmanId,
'visitType': visitType,
'status': status,
'priority': priority,
'planStartDate': planStartDate,
'planEndDate': planEndDate,
},
parse: (d) {
final m = (d as Map).cast<String, dynamic>();
return VisitPage(
list: (m['list'] as List? ?? [])
.map((e) => VisitInfo.fromJson((e as Map).cast<String, dynamic>()))
.toList(),
total: (m['total'] as num?)?.toInt() ?? 0,
page: (m['page'] as num?)?.toInt() ?? 1,
pageSize: (m['pageSize'] as num?)?.toInt() ?? pageSize,
);
},
);
Future<ApiResponse<VisitStats>> stats() => _client.get<VisitStats>(
'/customer-visits/stats',
parse: (d) => VisitStats.fromJson((d as Map).cast<String, dynamic>()),
);
Future<ApiResponse<VisitInfo>> detail(int id) => _client.get<VisitInfo>(
'/customer-visits/$id',
parse: (d) => VisitInfo.fromJson((d as Map).cast<String, dynamic>()),
);
Future<ApiResponse<VisitInfo>> create(Map<String, dynamic> body) =>
_client.post<VisitInfo>(
'/customer-visits',
body: body,
parse: (d) => VisitInfo.fromJson((d as Map).cast<String, dynamic>()),
);
/// 批量安排拜访:多选客户生成统一的待执行计划。
Future<ApiResponse<VisitBatchResult>> batchCreate(Map<String, dynamic> body) =>
_client.post<VisitBatchResult>(
'/customer-visits/batch',
body: body,
parse: (d) => VisitBatchResult.fromJson((d as Map).cast<String, dynamic>()),
);
Future<ApiResponse<VisitInfo>> update(int id, Map<String, dynamic> body) =>
_client.put<VisitInfo>(
'/customer-visits/$id',
body: body,
parse: (d) => VisitInfo.fromJson((d as Map).cast<String, dynamic>()),
);
/// 状态流转:待执行 → 已完成 / 取消 等。
Future<ApiResponse<VisitInfo>> updateStatus(int id, Map<String, dynamic> body) =>
_client.put<VisitInfo>(
'/customer-visits/$id/status',
body: body,
parse: (d) => VisitInfo.fromJson((d as Map).cast<String, dynamic>()),
);
/// 改派:更换跟进业务员(仅 ADMIN)。
Future<ApiResponse<VisitInfo>> reassign(int id, Map<String, dynamic> body) =>
_client.put<VisitInfo>(
'/customer-visits/$id/reassign',
body: body,
parse: (d) => VisitInfo.fromJson((d as Map).cast<String, dynamic>()),
);
Future<ApiResponse<void>> delete(int id) => _client.delete<void>('/customer-visits/$id');
}
+342
View File
@@ -0,0 +1,342 @@
import 'package:flutter/painting.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/utils/id_util.dart';
/// 数值下拉选项。
typedef ValueOption = ({int value, String label});
/// 拜访类型:1 首访摸排 2 方案沟通 3 商务二访 4 维保回访 5 应急巡检
const visitTypeOptions = <ValueOption>[
(value: 1, label: '首访摸排'),
(value: 2, label: '方案沟通'),
(value: 3, label: '商务二访'),
(value: 4, label: '维保回访'),
(value: 5, label: '应急巡检'),
];
/// 拜访方式:1 现场拜访 2 商务拜访 3 电话沟通 4 线上会议
const visitModeOptions = <ValueOption>[
(value: 1, label: '现场拜访'),
(value: 2, label: '商务拜访'),
(value: 3, label: '电话沟通'),
(value: 4, label: '线上会议'),
];
/// 优先级:1 特急 P1 2 加急 P2 3 常规 P3
const visitPriorityOptions = <ValueOption>[
(value: 1, label: '特急 · P1'),
(value: 2, label: '加急 · P2'),
(value: 3, label: '常规 · P3'),
];
/// 意向评级:1=A 高意向 2=B 中意向 3=C 低意向
const visitIntentOptions = <ValueOption>[
(value: 1, label: 'A级 · 高意向'),
(value: 2, label: 'B级 · 中意向'),
(value: 3, label: 'C级 · 低意向'),
];
/// 状态:0 待执行 1 已完成待复核 2 取证待技术复核 3 已立项转勘察 4 已归档 5 已取消
const visitStatusOptions = <ValueOption>[
(value: 0, label: '待执行'),
(value: 1, label: '已完成待复核'),
(value: 2, label: '取证待技术复核'),
(value: 3, label: '已立项转勘察'),
(value: 4, label: '已归档'),
(value: 5, label: '已取消'),
];
abstract final class VisitStatus {
static const pending = 0;
static const done = 1;
static const evidence = 2;
static const survey = 3;
static const archived = 4;
static const canceled = 5;
}
const visitStatusMap = <int, ({String text, ChipTone tone})>{
0: (text: '待执行', tone: ChipTone.warn),
1: (text: '已完成待复核', tone: ChipTone.primary),
2: (text: '取证待技术复核', tone: ChipTone.secondary),
3: (text: '已立项转勘察', tone: ChipTone.secondary),
4: (text: '已归档', tone: ChipTone.tertiary),
5: (text: '已取消', tone: ChipTone.error),
};
const visitPriorityMap = <int, ({String text, ChipTone tone})>{
1: (text: '特急 P1', tone: ChipTone.error),
2: (text: '加急 P2', tone: ChipTone.warn),
3: (text: '常规 P3', tone: ChipTone.muted),
};
String visitStatusText(int? s) => visitStatusMap[s]?.text ?? '未知';
ChipTone visitStatusTone(int? s) => visitStatusMap[s]?.tone ?? ChipTone.muted;
String visitPriorityText(int? p) => visitPriorityMap[p]?.text ?? '—';
ChipTone visitPriorityTone(int? p) => visitPriorityMap[p]?.tone ?? ChipTone.muted;
String optionLabel(List<ValueOption> options, int? value) =>
options.where((o) => o.value == value).map((o) => o.label).firstOrNull ?? '—';
/// 列表卡片左侧色条颜色。
Color visitStatusBarColor(int status) => switch (status) {
0 => AppColors.statusWarn,
1 => AppColors.primary,
2 || 3 => AppColors.secondary,
4 => AppColors.tertiaryContainer,
5 => AppColors.statusError,
_ => AppColors.borderRule,
};
/// 关联客户(列表/详情 include 的精简字段)。
class VisitCustomer {
const VisitCustomer({
required this.id,
this.customerNo,
this.customerName,
this.customerLevel,
this.contactPerson,
this.contactPhone,
this.address,
this.salesmanId,
});
final int id;
final String? customerNo;
final String? customerName;
final String? customerLevel;
final String? contactPerson;
final String? contactPhone;
final String? address;
final int? salesmanId;
factory VisitCustomer.fromJson(Map<String, dynamic> json) => VisitCustomer(
id: toIntOr(json['id'], 0),
customerNo: json['customerNo'] as String?,
customerName: json['customerName'] as String?,
customerLevel: json['customerLevel'] as String?,
contactPerson: json['contactPerson'] as String?,
contactPhone: json['contactPhone'] as String?,
address: json['address'] as String?,
salesmanId: toIntOrNull(json['salesmanId']),
);
}
/// 拜访台账,与后端 `/api/customer-visits` 契约对齐。
class VisitInfo {
const VisitInfo({
required this.id,
required this.visitNo,
required this.customerId,
required this.visitPerson,
required this.status,
this.visitPhone,
this.visitContent,
this.visitTime,
this.salesmanId,
this.willPlan,
this.visitType,
this.visitMode,
this.priority,
this.subject,
this.planTime,
this.planEndTime,
this.checkinTime,
this.checkinStatus,
this.checkinLng,
this.checkinLat,
this.spaceDesc,
this.intentLevel,
this.intentDesc,
this.demands,
this.companion,
this.receptionPerson,
this.receptionTitle,
this.receptionPhone,
this.resultRating,
this.nextVisitTime,
this.nextAction,
this.photos = const [],
this.photoUrls = const [],
this.surveyOrderId,
this.projectNo,
this.createdAt,
this.updatedAt,
this.customer,
this.salesmanName,
this.salesmanPhone,
});
final int id;
final String visitNo;
final int customerId;
final String visitPerson;
final int status;
final String? visitPhone;
final String? visitContent;
final String? visitTime;
final int? salesmanId;
final int? willPlan;
final int? visitType;
final int? visitMode;
final int? priority;
final String? subject;
final String? planTime;
final String? planEndTime;
final String? checkinTime;
final int? checkinStatus;
final double? checkinLng;
final double? checkinLat;
final String? spaceDesc;
final int? intentLevel;
final String? intentDesc;
final String? demands;
final String? companion;
final String? receptionPerson;
final String? receptionTitle;
final String? receptionPhone;
final String? resultRating;
final String? nextVisitTime;
final String? nextAction;
/// 现场照片对象 key 列表(持久化字段)
final List<String> photos;
/// 现场照片预签名访问 URL 列表(后端动态生成)
final List<String> photoUrls;
final int? surveyOrderId;
final String? projectNo;
final String? createdAt;
final String? updatedAt;
final VisitCustomer? customer;
final String? salesmanName;
final String? salesmanPhone;
factory VisitInfo.fromJson(Map<String, dynamic> json) => VisitInfo(
id: toIntOr(json['id'], 0),
visitNo: json['visitNo'] as String? ?? '',
customerId: toIntOr(json['customerId'], 0),
visitPerson: json['visitPerson'] as String? ?? '',
status: toIntOr(json['status'], 0),
visitPhone: json['visitPhone'] as String?,
visitContent: json['visitContent'] as String?,
visitTime: json['visitTime'] as String?,
salesmanId: toIntOrNull(json['salesmanId']),
willPlan: toIntOrNull(json['willPlan']),
visitType: toIntOrNull(json['visitType']),
visitMode: toIntOrNull(json['visitMode']),
priority: toIntOrNull(json['priority']),
subject: json['subject'] as String?,
planTime: json['planTime'] as String?,
planEndTime: json['planEndTime'] as String?,
checkinTime: json['checkinTime'] as String?,
checkinStatus: toIntOrNull(json['checkinStatus']),
checkinLng: (json['checkinLng'] as num?)?.toDouble(),
checkinLat: (json['checkinLat'] as num?)?.toDouble(),
spaceDesc: json['spaceDesc'] as String?,
intentLevel: toIntOrNull(json['intentLevel']),
intentDesc: json['intentDesc'] as String?,
demands: json['demands'] as String?,
companion: json['companion'] as String?,
receptionPerson: json['receptionPerson'] as String?,
receptionTitle: json['receptionTitle'] as String?,
receptionPhone: json['receptionPhone'] as String?,
resultRating: json['resultRating'] as String?,
nextVisitTime: json['nextVisitTime'] as String?,
nextAction: json['nextAction'] as String?,
photos: (json['photos'] as List? ?? []).map((e) => e.toString()).toList(),
photoUrls: (json['photoUrls'] as List? ?? []).map((e) => e.toString()).toList(),
surveyOrderId: toIntOrNull(json['surveyOrderId']),
projectNo: json['projectNo'] as String?,
createdAt: json['created_at'] as String?,
updatedAt: json['updated_at'] as String?,
customer: json['customer'] == null
? null
: VisitCustomer.fromJson((json['customer'] as Map).cast<String, dynamic>()),
salesmanName: json['salesmanName'] as String?,
salesmanPhone: json['salesmanPhone'] as String?,
);
String get customerName =>
customer?.customerName ?? '客户 #$customerId';
}
class VisitPage {
const VisitPage({
required this.list,
required this.total,
required this.page,
required this.pageSize,
});
final List<VisitInfo> list;
final int total;
final int page;
final int pageSize;
}
/// KPI 统计卡数据。
class VisitStats {
const VisitStats({
required this.total,
required this.pending,
required this.todayPlan,
required this.urgentPending,
required this.conversionRate,
required this.pendingReview,
required this.completed,
required this.converted,
required this.intentTotal,
});
final int total;
final int pending;
final int todayPlan;
final int urgentPending;
final num conversionRate;
final int pendingReview;
final int completed;
final int converted;
final int intentTotal;
factory VisitStats.fromJson(Map<String, dynamic> json) => VisitStats(
total: toIntOr(json['total'], 0),
pending: toIntOr(json['pending'], 0),
todayPlan: toIntOr(json['todayPlan'], 0),
urgentPending: toIntOr(json['urgentPending'], 0),
conversionRate: (json['conversionRate'] as num?) ?? 0,
pendingReview: toIntOr(json['pendingReview'], 0),
completed: toIntOr(json['completed'], 0),
converted: toIntOr(json['converted'], 0),
intentTotal: toIntOr(json['intentTotal'], 0),
);
static const empty = VisitStats(
total: 0,
pending: 0,
todayPlan: 0,
urgentPending: 0,
conversionRate: 0,
pendingReview: 0,
completed: 0,
converted: 0,
intentTotal: 0,
);
}
/// 批量安排拜访结果。
class VisitBatchResult {
const VisitBatchResult({required this.total, required this.ids});
final int total;
final List<int> ids;
factory VisitBatchResult.fromJson(Map<String, dynamic> json) => VisitBatchResult(
total: toIntOr(json['total'], 0),
ids: (json['ids'] as List? ?? []).map((e) => toIntOr(e, 0)).toList(),
);
}
@@ -0,0 +1,695 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/utils/format_util.dart';
import '../../../shared/widgets/app_feedback.dart';
import '../../../shared/widgets/x_card.dart';
import '../../../shared/widgets/x_common.dart';
import '../../../shared/widgets/x_icon.dart';
import '../../../shared/widgets/x_top_bar.dart';
import '../../auth/application/session_controller.dart';
import '../../customer/data/customer_api.dart';
import '../../customer/data/customer_models.dart';
import '../../customer/data/user_api.dart';
import '../data/visit_api.dart';
import '../data/visit_models.dart';
/// 批量安排拜访页,对应 `pages/visit/batch.vue`。
class VisitBatchPage extends ConsumerStatefulWidget {
const VisitBatchPage({super.key});
@override
ConsumerState<VisitBatchPage> createState() => _VisitBatchPageState();
}
class _VisitBatchPageState extends ConsumerState<VisitBatchPage> {
final _keyword = TextEditingController();
final _subject = TextEditingController();
final List<CustomerOption> _customers = [];
final List<int> _selectedIds = [];
List<SysUser> _salesmen = const [];
bool _customerLoading = false;
bool _saving = false;
bool _initialized = false;
String _planTime = todayStr();
String _planEndTime = '';
int _typeIndex = 0;
int _modeIndex = 0;
int _priorityIndex = 2;
int _salesmanIndex = -1;
late final List<String> _typeLabels = visitTypeOptions
.map((o) => o.label)
.toList();
late final List<String> _modeLabels = visitModeOptions
.map((o) => o.label)
.toList();
late final List<String> _priorityLabels = visitPriorityOptions
.map((o) => o.label)
.toList();
@override
void dispose() {
_keyword.dispose();
_subject.dispose();
super.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_initialized) return;
_initialized = true;
final preset = int.tryParse(
GoRouterState.of(context).uri.queryParameters['customerId'] ?? '',
);
if (preset != null && preset > 0) _selectedIds.add(preset);
_loadCustomers();
if (_isAdmin) _loadSalesmen();
}
bool get _isAdmin => ref.read(sessionProvider).user?.isAdmin ?? false;
int get _visitType => visitTypeOptions[_typeIndex].value;
int get _visitMode => visitModeOptions[_modeIndex].value;
int get _priority => visitPriorityOptions[_priorityIndex].value;
int? get _salesmanId =>
_salesmanIndex > 0 ? _salesmen[_salesmanIndex - 1].id : null;
List<String> get _salesmanLabels => [
'默认本人',
..._salesmen.map((s) => s.optionLabel),
];
List<CustomerOption> get _filteredCustomers {
final kw = _keyword.text.trim().toLowerCase();
if (kw.isEmpty) return _customers;
return _customers
.where(
(c) =>
c.customerName.toLowerCase().contains(kw) ||
c.customerNo.toLowerCase().contains(kw),
)
.toList();
}
Future<void> _loadCustomers() async {
_customerLoading = true;
try {
final res = await ref.read(customerApiProvider).options();
if (!mounted) return;
if (res.isOk && res.data != null) {
_customers
..clear()
..addAll(res.data!);
}
} catch (_) {
showAppToast('客户列表加载失败');
} finally {
if (mounted) setState(() => _customerLoading = false);
}
}
Future<void> _loadSalesmen() async {
try {
final list = await ref.read(userApiProvider).salesmanOptions();
if (mounted) setState(() => _salesmen = list);
} catch (_) {
if (mounted) setState(() => _salesmen = const []);
}
}
void _toggle(int id) {
setState(() {
if (_selectedIds.contains(id)) {
_selectedIds.remove(id);
} else {
_selectedIds.add(id);
}
});
}
void _selectAll() {
setState(() {
for (final c in _filteredCustomers) {
if (!_selectedIds.contains(c.id)) _selectedIds.add(c.id);
}
});
}
void _clearAll() => setState(_selectedIds.clear);
Future<void> _pickPlanStart() async {
final picked = await _pickDate(_planTime);
if (picked == null) return;
if (!mounted) return;
setState(() => _planTime = formatDate(picked));
}
Future<void> _pickPlanEnd() async {
final picked = await _pickDate(_planEndTime);
if (picked == null) return;
if (!mounted) return;
setState(() => _planEndTime = formatDate(picked));
}
Future<DateTime?> _pickDate(String current) {
return showDatePicker(
context: context,
initialDate: parseDate(current) ?? DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
}
Future<void> _pickOption({
required List<String> labels,
required int selectedIndex,
required ValueChanged<int> onSelected,
}) async {
final index = await showModalBottomSheet<int>(
context: context,
backgroundColor: AppColors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.lg)),
),
builder: (ctx) => SafeArea(
child: ListView(
shrinkWrap: true,
children: [
for (var i = 0; i < labels.length; i++)
ListTile(
title: Text(
labels[i],
style: AppTypography.bodyMd.copyWith(
color: AppColors.onSurface,
),
),
trailing: i == selectedIndex
? const XIcon('check', size: 18, color: AppColors.primary)
: null,
onTap: () => Navigator.of(ctx).pop(i),
),
],
),
),
);
if (index == null) return;
if (!mounted) return;
setState(() => onSelected(index));
}
Future<void> _onSave() async {
if (_selectedIds.isEmpty) {
showAppToast('请至少选择一个客户');
return;
}
if (_planTime.isEmpty) {
showAppToast('请选择计划日期');
return;
}
setState(() => _saving = true);
try {
final payload = <String, dynamic>{
'customerIds': List<int>.from(_selectedIds),
'planTime': _planTime,
'subject': _subject.text,
'visitType': _visitType,
'visitMode': _visitMode,
'priority': _priority,
};
if (_planEndTime.isNotEmpty) payload['planEndTime'] = _planEndTime;
final salesmanId = _salesmanId;
if (_isAdmin && salesmanId != null) payload['salesmanId'] = salesmanId;
final res = await ref.read(visitApiProvider).batchCreate(payload);
if (!mounted) return;
final data = res.data;
if (res.isOk && data != null) {
showAppToast('成功创建 ${data.total} 条拜访计划');
Future.delayed(const Duration(milliseconds: 800), () {
if (mounted) context.pop();
});
} else {
showAppToast(res.message.isEmpty ? '创建失败' : res.message);
}
} catch (_) {
if (mounted) showAppToast('创建失败,请重试');
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
return XPageShell(
topBar: XTopBar.nav(title: '批量安排拜访', onBack: () => context.pop()),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 60),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildPlanCard(),
_buildCustomerCard(),
Container(
margin: const EdgeInsets.fromLTRB(0, 24, 0, 40),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(9999),
boxShadow: const [
BoxShadow(
color: Color(0x4D0054CD),
blurRadius: 16,
offset: Offset(0, 6),
),
],
),
child: XPrimaryButton(
label: '创建 ${_selectedIds.length} 条拜访计划',
loading: _saving,
onPressed: _onSave,
),
),
],
),
),
);
}
Widget _buildPlanCard() {
return XCard(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 16),
margin: const EdgeInsets.only(bottom: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(0, 14, 0, 6),
child: Text(
'计划信息',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.onSurface,
),
),
),
_formItem(
label: '计划日期',
isRequired: true,
child: _pickerValue(
text: _planTime.isEmpty ? '请选择计划日期' : _planTime,
placeholder: _planTime.isEmpty,
trailingIcon: 'calendar',
onTap: _pickPlanStart,
),
),
_formDivider(),
_formItem(
label: '计划结束',
child: _pickerValue(
text: _planEndTime.isEmpty ? '选填' : _planEndTime,
placeholder: _planEndTime.isEmpty,
trailingIcon: 'calendar',
onTap: _pickPlanEnd,
),
),
_formDivider(),
_formItem(
label: '拜访主题',
child: _textInput(_subject, '默认:批量安排的拜访计划'),
),
_formDivider(),
_formItem(
label: '拜访类型',
child: _pickerValue(
text: _typeLabels[_typeIndex],
placeholder: false,
trailingIcon: 'forward',
onTap: () => _pickOption(
labels: _typeLabels,
selectedIndex: _typeIndex,
onSelected: (i) => _typeIndex = i,
),
),
),
_formDivider(),
_formItem(
label: '拜访方式',
child: _pickerValue(
text: _modeLabels[_modeIndex],
placeholder: false,
trailingIcon: 'forward',
onTap: () => _pickOption(
labels: _modeLabels,
selectedIndex: _modeIndex,
onSelected: (i) => _modeIndex = i,
),
),
),
_formDivider(),
_formItem(
label: '优先级',
child: _pickerValue(
text: _priorityLabels[_priorityIndex],
placeholder: false,
trailingIcon: 'forward',
onTap: () => _pickOption(
labels: _priorityLabels,
selectedIndex: _priorityIndex,
onSelected: (i) => _priorityIndex = i,
),
),
),
if (_isAdmin) ...[
_formDivider(),
_formItem(
label: '负责业务员',
child: _pickerValue(
text: _salesmanIndex >= 0
? _salesmanLabels[_salesmanIndex]
: '默认本人',
placeholder: _salesmanIndex < 0,
trailingIcon: 'forward',
onTap: () => _pickOption(
labels: _salesmanLabels,
selectedIndex: _salesmanIndex,
onSelected: (i) => _salesmanIndex = i,
),
),
),
],
],
),
);
}
Widget _buildCustomerCard() {
return XCard(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(0, 14, 0, 6),
child: Row(
children: [
const Text(
'选择客户',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.onSurface,
),
),
const Spacer(),
GestureDetector(
onTap: _selectAll,
child: const Text(
'全选',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
),
const SizedBox(width: 16),
GestureDetector(
onTap: _clearAll,
child: const Text(
'清空',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
),
],
),
),
_searchBar(),
Padding(
padding: const EdgeInsets.fromLTRB(0, 4, 0, 8),
child: Row(
children: [
const Text(
'已选 ',
style: TextStyle(fontSize: 13, color: AppColors.textMuted),
),
Text(
'${_selectedIds.length}',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
const Text(
' 个客户',
style: TextStyle(fontSize: 13, color: AppColors.textMuted),
),
],
),
),
if (_customerLoading)
_centerText('加载中...')
else if (_filteredCustomers.isEmpty)
_centerText('暂无可选客户')
else
for (final c in _filteredCustomers) _customerItem(c),
],
),
);
}
Widget _formItem({
required String label,
required Widget child,
bool isRequired = false,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
label,
style: AppTypography.labelBold.copyWith(
color: AppColors.onSurfaceVariant,
),
),
if (isRequired)
const Text(
' *',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.statusError,
),
),
],
),
const SizedBox(height: 8),
child,
],
),
);
}
Widget _formDivider() =>
const Divider(height: 1, thickness: 1, color: AppColors.borderRule);
Widget _pickerValue({
required String text,
required bool placeholder,
required String trailingIcon,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: AppColors.surfaceBright,
border: Border.all(color: AppColors.borderRule),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Expanded(
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15,
color: placeholder
? AppColors.textPlaceholder
: AppColors.onSurface,
),
),
),
XIcon(trailingIcon, size: 16, color: AppColors.outline),
],
),
),
);
}
Widget _textInput(TextEditingController controller, String hint) {
return Container(
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: AppColors.surfaceBright,
border: Border.all(color: AppColors.borderRule),
borderRadius: BorderRadius.circular(10),
),
child: TextField(
controller: controller,
style: const TextStyle(fontSize: 15, color: AppColors.onSurface),
cursorColor: AppColors.primary,
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
hintText: hint,
hintStyle: const TextStyle(
fontSize: 15,
color: AppColors.textPlaceholder,
),
),
),
);
}
Widget _searchBar() {
return Container(
height: 42,
margin: const EdgeInsets.fromLTRB(0, 6, 0, 8),
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: AppColors.surfaceBright,
border: Border.all(color: AppColors.borderRule),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
const XIcon('search', size: 18, color: AppColors.textMuted),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _keyword,
onChanged: (_) => setState(() {}),
style: const TextStyle(fontSize: 15, color: AppColors.onSurface),
cursorColor: AppColors.primary,
decoration: const InputDecoration(
isDense: true,
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
hintText: '按客户名称、编号筛选...',
hintStyle: TextStyle(
fontSize: 15,
color: AppColors.textPlaceholder,
),
),
),
),
],
),
);
}
Widget _centerText(String text) => Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Text(
text,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 14, color: AppColors.textMuted),
),
);
Widget _customerItem(CustomerOption c) {
final selected = _selectedIds.contains(c.id);
return GestureDetector(
onTap: () => _toggle(c.id),
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
decoration: BoxDecoration(
color: selected ? AppColors.surfaceContainerLow : AppColors.surfaceBright,
border: Border.all(
color: selected ? AppColors.primaryContainer : AppColors.borderRule,
),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Container(
width: 22,
height: 22,
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected ? AppColors.primaryContainer : Colors.white,
border: Border.all(
color: selected
? AppColors.primaryContainer
: AppColors.borderRule,
width: 1.5,
),
borderRadius: BorderRadius.circular(6),
),
child: selected
? const XIcon('check', size: 14, color: Colors.white)
: null,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
c.customerName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
color: AppColors.onSurface,
),
),
const SizedBox(height: 2),
Text(
c.customerNo,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.dataMono.copyWith(
fontSize: 11,
color: AppColors.textMuted,
),
),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,604 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/utils/format_util.dart';
import '../../../shared/widgets/app_feedback.dart';
import '../../../shared/widgets/x_card.dart';
import '../../../shared/widgets/x_chip.dart';
import '../../../shared/widgets/x_common.dart';
import '../../../shared/widgets/x_icon.dart';
import '../../../shared/widgets/x_top_bar.dart';
import '../../auth/application/session_controller.dart';
import '../../customer/data/user_api.dart';
import '../data/visit_api.dart';
import '../data/visit_models.dart';
/// 拜访详情页,对应 `pages/visit/detail.vue`。
class VisitDetailPage extends ConsumerStatefulWidget {
const VisitDetailPage({super.key, required this.id});
final int id;
@override
ConsumerState<VisitDetailPage> createState() => _VisitDetailPageState();
}
class _VisitDetailPageState extends ConsumerState<VisitDetailPage> {
VisitInfo? _visit;
bool _loading = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() => _loading = true);
try {
final res = await ref.read(visitApiProvider).detail(widget.id);
if (!mounted) return;
if (res.isOk && res.data != null) {
setState(() => _visit = res.data);
} else {
showAppToast(res.message.isNotEmpty ? res.message : '拜访记录不存在');
}
} catch (_) {
showAppToast('加载失败');
} finally {
if (mounted) setState(() => _loading = false);
}
}
String _findLabel(List<ValueOption> options, int? value) {
if (value == null) return '';
return options.where((o) => o.value == value).map((o) => o.label).firstOrNull ??
'';
}
String _planRange(VisitInfo visit) {
if (visit.planTime == null || visit.planTime!.isEmpty) return '未排期';
final start = formatDate(visit.planTime);
if (visit.planEndTime == null || visit.planEndTime!.isEmpty) return start;
return '$start ~ ${formatDate(visit.planEndTime)}';
}
String _receptionText(VisitInfo visit) {
if (visit.receptionPerson == null || visit.receptionPerson!.isEmpty) return '-';
final title = visit.receptionTitle != null && visit.receptionTitle!.isNotEmpty
? '(${visit.receptionTitle})'
: '';
final phone = visit.receptionPhone != null && visit.receptionPhone!.isNotEmpty
? ' · ${visit.receptionPhone}'
: '';
return '${visit.receptionPerson}$title$phone';
}
void _goEdit(bool canEdit) {
if (!canEdit) {
showAppToast('仅待执行的拜访可编辑');
return;
}
context.push('/visit/form?id=${widget.id}').then((_) {
if (mounted) _load();
});
}
void _goExecute() {
context.push('/visit/execute/${widget.id}').then((_) {
if (mounted) _load();
});
}
Future<void> _onCancelVisit() async {
final confirmed = await showAppConfirm(
title: '取消拜访',
content: '确定取消该拜访计划吗?取消后不可再执行。',
);
if (!confirmed) return;
try {
final res = await ref
.read(visitApiProvider)
.updateStatus(widget.id, {'status': VisitStatus.canceled});
if (res.isOk) {
showAppToast('已取消', success: true);
_load();
} else {
showAppToast(res.message.isNotEmpty ? res.message : '操作失败');
}
} catch (_) {
showAppToast('操作失败');
}
}
Future<void> _onReassign() async {
try {
final salesmen = await ref.read(userApiProvider).salesmanOptions();
if (!mounted) return;
if (salesmen.isEmpty) {
showAppToast('暂无可选业务员');
return;
}
final index = await _showSalesmanSheet(
salesmen.map((s) => s.optionLabel).toList(),
);
if (index == null || !mounted) return;
final res = await ref
.read(visitApiProvider)
.reassign(widget.id, {'salesmanId': salesmen[index].id});
if (res.isOk) {
showAppToast('改派成功', success: true);
_load();
} else {
showAppToast(res.message.isNotEmpty ? res.message : '改派失败');
}
} catch (_) {
showAppToast('获取业务员失败');
}
}
Future<int?> _showSalesmanSheet(List<String> labels) {
return showModalBottomSheet<int>(
context: context,
backgroundColor: AppColors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.lg)),
),
builder: (ctx) => SafeArea(
child: ListView(
shrinkWrap: true,
children: [
for (var i = 0; i < labels.length; i++)
ListTile(
title: Text(
labels[i],
style: AppTypography.bodyMd.copyWith(color: AppColors.onSurface),
),
onTap: () => Navigator.of(ctx).pop(i),
),
],
),
),
);
}
void _previewPhoto(List<String> urls, int index) {
showDialog<void>(
context: context,
barrierColor: Colors.black,
builder: (_) => Dialog(
backgroundColor: Colors.transparent,
insetPadding: EdgeInsets.zero,
child: GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Center(
child: Image.network(urls[index], fit: BoxFit.contain),
),
),
),
);
}
@override
Widget build(BuildContext context) {
final visit = _visit;
final isAdmin = ref.watch(sessionProvider).user?.isAdmin ?? false;
final canEdit = visit?.status == VisitStatus.pending;
return XPageShell(
topBar: XTopBar.nav(
title: '拜访详情',
onBack: () => context.pop(),
trailing: GestureDetector(
onTap: () => _goEdit(canEdit),
behavior: HitTestBehavior.opaque,
child: XIcon(
'edit',
size: 20,
color: canEdit ? AppColors.primary : AppColors.outlineVariant,
),
),
),
child: visit == null
? (Center(
child: _loading
? const Text(
'加载中...',
style: TextStyle(fontSize: 15, color: AppColors.textMuted),
)
: const SizedBox.shrink(),
))
: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 60),
children: _buildContent(visit, canEdit, isAdmin),
),
);
}
List<Widget> _buildContent(VisitInfo visit, bool canEdit, bool isAdmin) {
final customer = visit.customer;
final photoList = visit.photoUrls.where((u) => u.isNotEmpty).toList();
final hasGps =
(visit.checkinLng ?? 0) != 0 && (visit.checkinLat ?? 0) != 0;
final hasExecution =
(visit.checkinTime != null && visit.checkinTime!.isNotEmpty) ||
(visit.checkinStatus ?? 0) > 0 ||
hasGps ||
(visit.spaceDesc != null && visit.spaceDesc!.isNotEmpty);
final hasResult =
(visit.visitTime != null && visit.visitTime!.isNotEmpty) ||
(visit.intentLevel ?? 0) > 0 ||
(visit.intentDesc != null && visit.intentDesc!.isNotEmpty) ||
(visit.demands != null && visit.demands!.isNotEmpty) ||
(visit.visitContent != null && visit.visitContent!.isNotEmpty) ||
(visit.companion != null && visit.companion!.isNotEmpty) ||
(visit.receptionPerson != null && visit.receptionPerson!.isNotEmpty) ||
(visit.resultRating != null && visit.resultRating!.isNotEmpty);
final hasNext =
(visit.nextVisitTime != null && visit.nextVisitTime!.isNotEmpty) ||
(visit.nextAction != null && visit.nextAction!.isNotEmpty);
final priority = visit.priority ?? 0;
final typeText = _findLabel(visitTypeOptions, visit.visitType);
final modeText = _findLabel(visitModeOptions, visit.visitMode);
return [
// 头部
XCard(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
visit.subject?.isNotEmpty == true ? visit.subject! : '客户拜访',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: AppColors.onSurface,
),
),
),
const SizedBox(width: 8),
XChip(
visitStatusText(visit.status),
tone: visitStatusTone(visit.status),
),
],
),
if (priority > 0 || typeText.isNotEmpty || modeText.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Wrap(
spacing: 6,
runSpacing: 6,
children: [
if (priority > 0)
XChip(
visitPriorityText(visit.priority),
tone: visitPriorityTone(visit.priority),
),
if (typeText.isNotEmpty) XChip(typeText, tone: ChipTone.muted),
if (modeText.isNotEmpty) XChip(modeText, tone: ChipTone.muted),
],
),
),
Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
children: [
const Text(
'拜访单号',
style: TextStyle(fontSize: 12, color: AppColors.textMuted),
),
const SizedBox(width: 8),
Text(
visit.visitNo,
style: AppTypography.dataMono.copyWith(
fontSize: 12,
color: AppColors.onSurface,
),
),
],
),
),
],
),
),
// 客户信息
_sectionTitle('groups', AppColors.primary, '客户信息'),
XCard(
onTap: visit.customerId > 0
? () => context.push('/customer/detail/${visit.customerId}')
: null,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: _infoColumn([
_infoItem(
'business',
'客户名称',
customer?.customerName?.isNotEmpty == true
? customer!.customerName!
: '-',
link: true,
),
if (customer?.customerNo?.isNotEmpty == true)
_infoItem('category', '客户编号', customer!.customerNo!, mono: true),
if (customer?.contactPerson?.isNotEmpty == true)
_infoItem('person', '联系人', customer!.contactPerson!),
if (customer?.contactPhone?.isNotEmpty == true)
_infoItem('call', '联系电话', customer!.contactPhone!, mono: true),
if (customer?.address?.isNotEmpty == true)
_infoItem('location', '客户地址', customer!.address!),
]),
),
// 计划安排
_sectionTitle('schedule', AppColors.secondary, '计划安排'),
_plainInfoCard([
_infoItem('calendar', '计划时间', _planRange(visit)),
_infoItem(
'person',
'拜访人',
'${visit.visitPerson.isNotEmpty ? visit.visitPerson : '-'}'
'${visit.visitPhone != null && visit.visitPhone!.isNotEmpty ? ' · ${visit.visitPhone}' : ''}',
),
if (visit.salesmanName != null && visit.salesmanName!.isNotEmpty)
_infoItem(
'verified',
'负责业务员',
'${visit.salesmanName}'
'${visit.salesmanPhone != null && visit.salesmanPhone!.isNotEmpty ? ' · ${visit.salesmanPhone}' : ''}',
),
]),
// 现场执行
if (hasExecution) ...[
_sectionTitle('location', AppColors.tertiary, '现场执行'),
_plainInfoCard([
if (visit.checkinTime != null && visit.checkinTime!.isNotEmpty)
_infoItem('clock', '到场签到', formatDateTime(visit.checkinTime)),
if (hasGps)
_infoItem(
'location',
'GPS 坐标',
'${visit.checkinLat}, ${visit.checkinLng}',
mono: true,
),
if (visit.spaceDesc != null && visit.spaceDesc!.isNotEmpty)
_infoItem('business', '现场情况', visit.spaceDesc!),
]),
],
// 拜访结果
if (hasResult) ...[
_sectionTitle('check-circle', AppColors.primary, '拜访结果'),
_plainInfoCard([
if (visit.visitTime != null && visit.visitTime!.isNotEmpty)
_infoItem('clock', '实际拜访时间', formatDate(visit.visitTime)),
if ((visit.intentLevel ?? 0) > 0)
_infoItem(
'trend-up',
'意向评级',
_findLabel(visitIntentOptions, visit.intentLevel),
),
if (visit.intentDesc != null && visit.intentDesc!.isNotEmpty)
_infoItem('chart', '意向说明', visit.intentDesc!),
if (visit.demands != null && visit.demands!.isNotEmpty)
_infoItem('bolt', '客户需求', visit.demands!),
if (visit.visitContent != null && visit.visitContent!.isNotEmpty)
_infoItem('edit', '拜访内容', visit.visitContent!),
if (visit.companion != null && visit.companion!.isNotEmpty)
_infoItem('person', '随行人员', visit.companion!),
if (visit.receptionPerson != null && visit.receptionPerson!.isNotEmpty)
_infoItem('person', '接待人', _receptionText(visit)),
if (visit.resultRating != null && visit.resultRating!.isNotEmpty)
_infoItem('verified', '结果评价', visit.resultRating!),
]),
],
// 后续计划
if (hasNext) ...[
_sectionTitle('forward', AppColors.secondary, '后续计划'),
_plainInfoCard([
if (visit.nextVisitTime != null && visit.nextVisitTime!.isNotEmpty)
_infoItem('calendar', '下次拜访', formatDate(visit.nextVisitTime)),
if (visit.nextAction != null && visit.nextAction!.isNotEmpty)
_infoItem('forward', '下一步动作', visit.nextAction!),
]),
],
// 现场照片
if (photoList.isNotEmpty) ...[
_sectionTitle('camera', AppColors.primary, '现场照片'),
LayoutBuilder(
builder: (context, constraints) {
final width = (constraints.maxWidth - 10) / 2;
return Wrap(
spacing: 10,
runSpacing: 10,
children: [
for (var i = 0; i < photoList.length; i++)
GestureDetector(
onTap: () => _previewPhoto(photoList, i),
child: ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.lg),
child: Container(
width: width,
height: 140,
color: AppColors.surfaceVariant,
child: Image.network(photoList[i], fit: BoxFit.cover),
),
),
),
],
);
},
),
],
// 流程信息
_sectionTitle('verified', AppColors.tertiary, '流程信息'),
_plainInfoCard([
if ((visit.surveyOrderId ?? 0) > 0 ||
(visit.projectNo != null && visit.projectNo!.isNotEmpty))
_infoItem(
'construction',
'关联立项',
visit.projectNo != null && visit.projectNo!.isNotEmpty
? visit.projectNo!
: '勘察单 #${visit.surveyOrderId}',
mono: true,
),
_infoItem('clock', '创建时间', formatDateTime(visit.createdAt)),
]),
// 业务员操作
_buildActions(canEdit, isAdmin),
];
}
Widget _buildActions(bool canEdit, bool isAdmin) {
if (!canEdit && !isAdmin) {
return Padding(
padding: const EdgeInsets.only(top: 24),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColors.surfaceBright,
border: Border.all(color: AppColors.borderRule),
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: const Text(
'当前状态下无需业务员操作,取证复核 / 立项 / 归档请在 Web 后台办理',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: AppColors.textMuted),
),
),
);
}
return Padding(
padding: const EdgeInsets.only(top: 24),
child: Column(
children: [
if (canEdit)
XPrimaryButton(
label: '开始执行并完成',
onPressed: _goExecute,
pill: false,
height: 48,
fontSize: 15,
margin: const EdgeInsets.only(bottom: 12),
),
if (canEdit)
XPrimaryButton(
label: '取消拜访',
onPressed: _onCancelVisit,
pill: false,
height: 48,
fontSize: 15,
background: AppColors.surface,
foreground: AppColors.statusError,
borderColor: AppColors.borderRule,
margin: const EdgeInsets.only(bottom: 12),
),
if (isAdmin)
XPrimaryButton(
label: '改派业务员',
onPressed: _onReassign,
pill: false,
height: 48,
fontSize: 15,
background: AppColors.surface,
foreground: AppColors.statusError,
borderColor: AppColors.borderRule,
),
],
),
);
}
Widget _sectionTitle(String icon, Color iconColor, String text) {
return Padding(
padding: const EdgeInsets.only(top: 22, bottom: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
XIcon(icon, size: 17, color: iconColor),
const SizedBox(width: 6),
Text(
text,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.onSurface,
),
),
],
),
);
}
Widget _plainInfoCard(List<Widget> items) {
return XCard(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: _infoColumn(items),
);
}
Widget _infoColumn(List<Widget> items) {
final children = <Widget>[];
for (var i = 0; i < items.length; i++) {
if (i > 0) {
children.add(
const Divider(height: 1, thickness: 1, color: AppColors.borderRule),
);
}
children.add(items[i]);
}
return Column(children: children);
}
Widget _infoItem(
String icon,
String label,
String value, {
bool mono = false,
bool link = false,
}) {
final color = link ? AppColors.primary : AppColors.onSurface;
final style = mono
? AppTypography.dataMono.copyWith(fontSize: 15, color: color)
: TextStyle(fontSize: 15, color: color);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 13),
child: Row(
children: [
XIcon(icon, size: 18, color: AppColors.textMuted),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppColors.textMuted,
),
),
Text(value, style: style),
],
),
),
],
),
);
}
}
@@ -0,0 +1,947 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import '../../../core/network/api_response.dart';
import '../../../core/providers.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/utils/format_util.dart';
import '../../../shared/widgets/app_feedback.dart';
import '../../../shared/widgets/x_card.dart';
import '../../../shared/widgets/x_chip.dart';
import '../../../shared/widgets/x_common.dart';
import '../../../shared/widgets/x_icon.dart';
import '../../../shared/widgets/x_top_bar.dart';
import '../data/visit_api.dart';
import '../data/visit_models.dart';
/// 拜访执行页,对应 `pages/visit/execute.vue`。
class VisitExecutePage extends ConsumerStatefulWidget {
const VisitExecutePage({super.key, required this.id});
final int id;
@override
ConsumerState<VisitExecutePage> createState() => _VisitExecutePageState();
}
/// 现场照片槽位:key 为对象存储 key(落库),url 为预签名访问地址(预览)。
class _PhotoSlot {
String key = '';
String url = '';
}
class _VisitExecutePageState extends ConsumerState<VisitExecutePage> {
VisitInfo? _visit;
bool _loading = false;
bool _checking = false;
bool _submitting = false;
final _spaceDesc = TextEditingController();
final _visitContent = TextEditingController();
final _demands = TextEditingController();
final _intentDesc = TextEditingController();
int? _intentLevel;
final List<_PhotoSlot> _photoSlots = [_PhotoSlot(), _PhotoSlot()];
NavigatorState? _uploadingNavigator;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_spaceDesc.dispose();
_visitContent.dispose();
_demands.dispose();
_intentDesc.dispose();
super.dispose();
}
VisitApi get _api => ref.read(visitApiProvider);
bool get _checkedIn => _visit?.checkinStatus == 1;
bool get _hasGps =>
(_visit?.checkinLng ?? 0) != 0 && (_visit?.checkinLat ?? 0) != 0;
String get _gpsText => '${_visit?.checkinLat}, ${_visit?.checkinLng}';
String get _planRange {
final v = _visit;
final planTime = v?.planTime;
if (planTime == null || planTime.isEmpty) return '未排期';
final start = formatDate(planTime);
final endTime = v?.planEndTime;
if (endTime == null || endTime.isEmpty) return start;
return '$start ~ ${formatDate(endTime)}';
}
Future<void> _load() async {
setState(() => _loading = true);
try {
final res = await _api.detail(widget.id);
if (!mounted) return;
final d = res.data;
if (!res.isOk || d == null) {
showAppToast(res.message.isEmpty ? '拜访记录不存在' : res.message);
return;
}
if (d.status != VisitStatus.pending) {
showAppToast('该拜访已处理,无需执行');
Future.delayed(const Duration(milliseconds: 600), () {
if (mounted) context.pop();
});
return;
}
_spaceDesc.text = d.spaceDesc ?? '';
_visitContent.text = d.visitContent ?? '';
_demands.text = d.demands ?? '';
_intentDesc.text = d.intentDesc ?? '';
setState(() {
_visit = d;
_intentLevel = d.intentLevel;
});
} catch (_) {
if (mounted) showAppToast('加载失败');
} finally {
if (mounted) setState(() => _loading = false);
}
}
/// 到场签到:获取 GPS 后落库(后端 update 透传 checkinLng/checkinLat/checkinStatus)。
Future<void> _onCheckin() async {
setState(() => _checking = true);
try {
Position? loc;
try {
final permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
loc = null;
} else {
loc = await Geolocator.getCurrentPosition();
}
} catch (_) {
loc = null;
}
if (loc == null) {
showAppToast('定位失败,请检查定位权限');
return;
}
final res = await _api.update(widget.id, {
'checkinLng': double.parse(loc.longitude.toStringAsFixed(6)),
'checkinLat': double.parse(loc.latitude.toStringAsFixed(6)),
'checkinStatus': 1,
'checkinTime': nowDateTimeStr(),
});
if (!mounted) return;
if (res.isOk) {
showAppToast('签到成功', success: true);
await _load();
} else {
showAppToast(res.message.isEmpty ? '签到失败' : res.message);
}
} catch (_) {
if (mounted) showAppToast('签到失败,请重试');
} finally {
if (mounted) setState(() => _checking = false);
}
}
void _onPhotoTap(int index) {
final slot = _photoSlots[index];
if (slot.url.isNotEmpty) {
_previewPhotos(index);
} else {
_pickPhoto(index);
}
}
Future<void> _pickPhoto(int index) async {
final source = await showModalBottomSheet<ImageSource>(
context: context,
backgroundColor: AppColors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.lg)),
),
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: Text(
'拍照',
style: AppTypography.bodyMd.copyWith(color: AppColors.onSurface),
),
onTap: () => Navigator.of(ctx).pop(ImageSource.camera),
),
ListTile(
title: Text(
'从相册选择',
style: AppTypography.bodyMd.copyWith(color: AppColors.onSurface),
),
onTap: () => Navigator.of(ctx).pop(ImageSource.gallery),
),
],
),
),
);
if (source == null) return;
if (!mounted) return;
final file = await ImagePicker().pickImage(
source: source,
imageQuality: 80,
);
if (file == null) return;
if (!mounted) return;
_showUploading();
try {
// 严格依赖对象存储:上传失败直接抛出,不写入本地槽位。
final up = await ref.read(apiClientProvider).uploadImage(file.path);
if (mounted) {
setState(() {
_photoSlots[index]
..key = up.key
..url = up.url;
});
showAppToast('上传成功', success: true);
}
} catch (e) {
final message = e is ApiException && e.message.isNotEmpty
? e.message
: '照片上传失败';
showAppToast(message);
} finally {
_hideUploading();
}
}
void _removePhoto(int index) {
setState(() {
_photoSlots[index]
..key = ''
..url = '';
});
}
void _previewPhotos(int index) {
final urls = _photoSlots
.where((s) => s.url.isNotEmpty)
.map((s) => s.url)
.toList();
if (urls.isEmpty) return;
final current = urls.indexOf(_photoSlots[index].url);
showDialog<void>(
context: context,
barrierColor: Colors.black,
builder: (ctx) => Dialog.fullscreen(
backgroundColor: Colors.black,
child: Stack(
children: [
GestureDetector(
onTap: () => Navigator.of(ctx).pop(),
child: PageView.builder(
controller: PageController(
initialPage: current < 0 ? 0 : current,
),
itemCount: urls.length,
itemBuilder: (_, i) => Center(
child: Image.network(urls[i], fit: BoxFit.contain),
),
),
),
Positioned(
right: 12,
top: MediaQuery.paddingOf(ctx).top + 12,
child: GestureDetector(
onTap: () => Navigator.of(ctx).pop(),
child: const XIcon('close', size: 24, color: Colors.white),
),
),
],
),
),
);
}
void _showUploading() {
// 提前抓取根导航器,页面被销毁时也能正常关闭上传遮罩。
_uploadingNavigator = Navigator.of(context, rootNavigator: true);
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => const _UploadingDialog(),
);
}
void _hideUploading() {
_uploadingNavigator?.pop();
_uploadingNavigator = null;
}
Future<void> _onSubmit() async {
if (!_checkedIn) {
final go = await showAppConfirm(
title: '尚未签到',
content: '还未完成到场签到,确定直接提交吗?',
);
if (!go) return;
}
final photos = _photoSlots
.where((s) => s.key.isNotEmpty)
.map((s) => s.key)
.toList();
setState(() => _submitting = true);
try {
final payload = <String, dynamic>{
'spaceDesc': _spaceDesc.text,
'visitContent': _visitContent.text,
'demands': _demands.text,
'intentDesc': _intentDesc.text,
};
final intentLevel = _intentLevel;
if (intentLevel != null && intentLevel != 0) {
payload['intentLevel'] = intentLevel;
}
if (photos.isNotEmpty) payload['photos'] = photos;
final upd = await _api.update(widget.id, payload);
if (!mounted) return;
if (!upd.isOk) {
showAppToast(upd.message.isEmpty ? '保存失败' : upd.message);
return;
}
final res = await _api.updateStatus(widget.id, {
'status': VisitStatus.done,
});
if (!mounted) return;
if (res.isOk) {
showAppToast('拜访已完成', success: true);
Future.delayed(const Duration(milliseconds: 600), () {
if (mounted) context.pop();
});
} else {
showAppToast(res.message.isEmpty ? '状态更新失败' : res.message);
}
} catch (_) {
if (mounted) showAppToast('提交失败,请重试');
} finally {
if (mounted) setState(() => _submitting = false);
}
}
@override
Widget build(BuildContext context) {
final visit = _visit;
return XPageShell(
topBar: XTopBar.nav(title: '拜访执行', onBack: () => context.pop()),
child: visit == null
? (_loading
? const Center(child: XPlaceholder(text: '加载中...'))
: const SizedBox.shrink())
: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 60),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildTaskCard(visit),
_sectionTitle(
'到场签到',
icon: 'location',
iconColor: AppColors.tertiary,
),
_buildCheckinCard(),
_sectionTitle(
'现场记录',
icon: 'edit',
iconColor: AppColors.secondary,
),
_buildRecordCard(),
_sectionTitle(
'意向评级',
icon: 'trend-up',
iconColor: AppColors.primary,
),
_buildIntentCard(),
_sectionTitle(
'现场照片',
icon: 'camera',
iconColor: AppColors.primary,
hint: '最多 2 张',
),
_buildPhotoGrid(),
_pillButton(
label: '提交并标记完成',
icon: 'check',
background: AppColors.primary,
fontSize: 16,
shadow: true,
loading: _submitting,
margin: const EdgeInsets.fromLTRB(16, 24, 16, 40),
onPressed: _onSubmit,
),
],
),
),
);
}
Widget _sectionTitle(
String text, {
required String icon,
required Color iconColor,
String? hint,
}) {
return Padding(
padding: const EdgeInsets.fromLTRB(0, 22, 0, 10),
child: Row(
children: [
XIcon(icon, size: 17, color: iconColor),
const SizedBox(width: 6),
Text(
text,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.onSurface,
),
),
if (hint != null) ...[
const SizedBox(width: 6),
Text(
hint,
style: const TextStyle(fontSize: 12, color: AppColors.textMuted),
),
],
],
),
);
}
Widget _buildTaskCard(VisitInfo visit) {
final customer = visit.customer;
final address = customer?.address;
final subject = visit.subject;
return XCard(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
subject == null || subject.isEmpty ? '客户拜访' : subject,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.onSurface,
),
),
const SizedBox(height: 4),
Text(
visit.visitNo,
style: AppTypography.dataMono.copyWith(
color: AppColors.textMuted,
),
),
],
),
),
const SizedBox(width: 10),
XChip(
visitStatusText(visit.status),
tone: visitStatusTone(visit.status),
),
],
),
const SizedBox(height: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_taskLine(
'business',
AppColors.primary,
customer?.customerName ?? '未知客户',
),
if (address != null && address.isNotEmpty) ...[
const SizedBox(height: 8),
_taskLine('location', AppColors.textMuted, address),
],
const SizedBox(height: 8),
_taskLine(
'schedule',
AppColors.statusWarn,
'计划时间:$_planRange',
),
],
),
],
),
);
}
Widget _taskLine(String icon, Color color, String text) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
XIcon(icon, size: 18, color: color),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: const TextStyle(
fontSize: 14,
color: AppColors.onSurfaceVariant,
),
),
),
],
);
}
Widget _buildCheckinCard() {
return XCard(
padding: const EdgeInsets.all(16),
child: _checkedIn ? _buildCheckinDone() : _buildCheckinAction(),
);
}
Widget _buildCheckinDone() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
XIcon('check-circle', size: 20, color: AppColors.tertiary),
SizedBox(width: 6),
Text(
'已签到',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.tertiary,
),
),
],
),
const SizedBox(height: 6),
Text(
'签到时间:${formatDateTime(_visit?.checkinTime)}',
style: const TextStyle(
fontSize: 13,
color: AppColors.onSurfaceVariant,
),
),
if (_hasGps) ...[
const SizedBox(height: 6),
Text(
'坐标:$_gpsText',
style: AppTypography.dataMono.copyWith(
fontSize: 13,
color: AppColors.onSurfaceVariant,
),
),
],
],
);
}
Widget _buildCheckinAction() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_pillButton(
label: '获取定位并签到',
icon: 'location',
background: AppColors.tertiary,
height: 46,
radius: 10,
loading: _checking,
onPressed: _onCheckin,
),
const SizedBox(height: 10),
const Text(
'签到将记录当前时间与 GPS 坐标,请到达客户现场后操作',
style: TextStyle(fontSize: 12, color: AppColors.textMuted),
),
],
);
}
Widget _buildRecordCard() {
return XCard(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
child: Column(
children: [
_textAreaField('现场情况', _spaceDesc, '记录配电房、设备与现场的实际情况...'),
_fieldDivider(),
_textAreaField('沟通纪要', _visitContent, '本次与客户沟通的主要内容...'),
_fieldDivider(),
_textAreaField('客户需求', _demands, '客户提出的需求与关注点...'),
],
),
);
}
Widget _fieldDivider() =>
const Divider(height: 1, thickness: 1, color: AppColors.borderRule);
Widget _textAreaField(
String label,
TextEditingController controller,
String hint,
) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_fieldLabel(label),
const SizedBox(height: 8),
Container(
height: 84,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: AppColors.surfaceBright,
border: Border.all(color: AppColors.borderRule),
borderRadius: BorderRadius.circular(10),
),
child: TextField(
controller: controller,
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
style: const TextStyle(fontSize: 15, color: AppColors.onSurface),
cursorColor: AppColors.primary,
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
hintText: hint,
hintStyle: const TextStyle(
fontSize: 15,
color: AppColors.textPlaceholder,
),
),
),
),
],
),
);
}
Widget _fieldLabel(String label) => Text(
label,
style: AppTypography.labelBold.copyWith(color: AppColors.onSurfaceVariant),
);
Widget _buildIntentCard() {
return XCard(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Row(
children: [
for (var i = 0; i < visitIntentOptions.length; i++) ...[
if (i > 0) const SizedBox(width: 10),
Expanded(child: _intentChip(visitIntentOptions[i])),
],
],
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_fieldLabel('意向说明'),
const SizedBox(height: 8),
Container(
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: AppColors.surfaceBright,
border: Border.all(color: AppColors.borderRule),
borderRadius: BorderRadius.circular(10),
),
child: TextField(
controller: _intentDesc,
style: const TextStyle(
fontSize: 15,
color: AppColors.onSurface,
),
cursorColor: AppColors.primary,
decoration: const InputDecoration(
isDense: true,
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
hintText: '选填,补充判断依据',
hintStyle: TextStyle(
fontSize: 15,
color: AppColors.textPlaceholder,
),
),
),
),
],
),
),
],
),
);
}
Widget _intentChip(ValueOption option) {
final active = _intentLevel == option.value;
return GestureDetector(
onTap: () => setState(() => _intentLevel = option.value),
child: Container(
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
color: active ? AppColors.surfaceVariant : AppColors.surfaceBright,
border: Border.all(
color: active ? AppColors.primary : AppColors.borderRule,
),
borderRadius: BorderRadius.circular(10),
),
child: Text(
option.label,
style: TextStyle(
fontSize: 13,
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
color: active ? AppColors.primary : AppColors.onSurfaceVariant,
),
),
),
);
}
Widget _buildPhotoGrid() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
for (var i = 0; i < _photoSlots.length; i++) ...[
if (i > 0) const SizedBox(width: 10),
Expanded(child: _photoSlot(i)),
],
],
),
const SizedBox(height: 10),
const Row(
children: [
XIcon('warning', size: 14, color: AppColors.outline),
SizedBox(width: 6),
Expanded(
child: Text(
'照片上传依赖对象存储服务,上传失败将无法提交',
style: TextStyle(fontSize: 12, color: AppColors.textMuted),
),
),
],
),
],
);
}
Widget _photoSlot(int index) {
final slot = _photoSlots[index];
final filled = slot.url.isNotEmpty;
return GestureDetector(
onTap: () => _onPhotoTap(index),
child: Container(
height: 120,
decoration: BoxDecoration(
color: AppColors.surfaceBright,
border: Border.all(color: AppColors.borderRule),
borderRadius: BorderRadius.circular(12),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Stack(
fit: StackFit.expand,
children: [
if (filled)
Image.network(slot.url, fit: BoxFit.cover)
else
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const XIcon('camera', size: 28, color: AppColors.outline),
const SizedBox(height: 6),
Text(
'现场照片 ${index + 1}',
style: const TextStyle(
fontSize: 12,
color: AppColors.textMuted,
),
),
],
),
if (filled)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 10),
color: const Color(0x8C0D1C2C),
child: Row(
children: [
Text(
'现场照片 ${index + 1}',
style: const TextStyle(
fontSize: 11,
color: Colors.white,
),
),
const Spacer(),
GestureDetector(
onTap: () => _removePhoto(index),
child: const XIcon(
'delete',
size: 15,
color: Colors.white,
),
),
],
),
),
),
],
),
),
),
);
}
Widget _pillButton({
required String label,
required String icon,
required Color background,
required VoidCallback onPressed,
bool loading = false,
double height = 48,
double radius = 9999,
double fontSize = 15,
bool shadow = false,
EdgeInsetsGeometry? margin,
}) {
return Padding(
padding: margin ?? EdgeInsets.zero,
child: Container(
height: height,
width: double.infinity,
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(radius),
boxShadow: shadow
? const [
BoxShadow(
color: Color(0x4D0054CD),
blurRadius: 16,
offset: Offset(0, 6),
),
]
: null,
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(radius),
child: InkWell(
onTap: loading ? null : onPressed,
borderRadius: BorderRadius.circular(radius),
child: Center(
child: loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(Colors.white),
),
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
XIcon(icon, size: 18, color: Colors.white),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
],
),
),
),
),
),
);
}
}
/// `uni.showLoading({ title: '上传中...' })` 的等价实现。
class _UploadingDialog extends StatelessWidget {
const _UploadingDialog();
@override
Widget build(BuildContext context) {
return const Dialog(
backgroundColor: Color(0xE60D1C2C),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(10)),
),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(Colors.white),
),
),
SizedBox(width: 10),
Text(
'上传中...',
style: TextStyle(fontSize: 14, color: Colors.white),
),
],
),
),
);
}
}
@@ -0,0 +1,550 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/utils/format_util.dart';
import '../../../shared/widgets/app_feedback.dart';
import '../../../shared/widgets/x_card.dart';
import '../../../shared/widgets/x_common.dart';
import '../../../shared/widgets/x_icon.dart';
import '../../../shared/widgets/x_top_bar.dart';
import '../../auth/application/session_controller.dart';
import '../../customer/data/customer_api.dart';
import '../../customer/data/customer_models.dart';
import '../../customer/data/user_api.dart';
import '../data/visit_api.dart';
import '../data/visit_models.dart';
/// 拜访新建 / 编辑页,对应 `pages/visit/form.vue`。
class VisitFormPage extends ConsumerStatefulWidget {
const VisitFormPage({super.key, this.id});
/// 为空表示新增,否则为编辑。
final int? id;
@override
ConsumerState<VisitFormPage> createState() => _VisitFormPageState();
}
class _VisitFormPageState extends ConsumerState<VisitFormPage> {
final TextEditingController _subject = TextEditingController();
final TextEditingController _visitPerson = TextEditingController();
final TextEditingController _visitPhone = TextEditingController();
final TextEditingController _visitContent = TextEditingController();
final List<String> _typeLabels = visitTypeOptions.map((o) => o.label).toList();
final List<String> _modeLabels = visitModeOptions.map((o) => o.label).toList();
final List<String> _priorityLabels =
visitPriorityOptions.map((o) => o.label).toList();
List<CustomerOption> _customers = const [];
List<SysUser> _salesmen = const [];
late final bool _isEdit;
late final bool _isAdmin;
int? _customerId;
int _typeIndex = 0;
int _modeIndex = 0;
int _priorityIndex = 2;
int _salesmanIndex = -1;
String _planTime = '';
String _planEndTime = '';
bool _saving = false;
@override
void initState() {
super.initState();
_isEdit = widget.id != null;
final user = ref.read(sessionProvider).user;
_isAdmin = user?.isAdmin ?? false;
_planTime = todayStr();
_visitPerson.text = user?.realName ?? '';
_init();
}
@override
void dispose() {
_subject.dispose();
_visitPerson.dispose();
_visitPhone.dispose();
_visitContent.dispose();
super.dispose();
}
Future<void> _init() async {
await _loadCustomers();
if (_isAdmin) await _loadSalesmen();
if (widget.id != null) await _loadDetail();
}
Future<void> _loadCustomers() async {
try {
final res = await ref.read(customerApiProvider).options();
if (res.isOk && res.data != null && mounted) {
setState(() => _customers = res.data!);
}
} catch (_) {
// 客户列表加载失败由保存前的校验兜底
}
}
Future<void> _loadSalesmen() async {
try {
final list = await ref.read(userApiProvider).salesmanOptions();
if (mounted) setState(() => _salesmen = list);
} catch (_) {
if (mounted) setState(() => _salesmen = const []);
}
}
Future<void> _loadDetail() async {
final res = await ref.read(visitApiProvider).detail(widget.id!);
if (!res.isOk || res.data == null) {
showAppToast(res.message.isNotEmpty ? res.message : '拜访记录不存在');
return;
}
if (!mounted) return;
final detail = res.data!;
setState(() {
_customerId = detail.customerId;
_subject.text = detail.subject ?? '';
_typeIndex = _indexOf(
visitTypeOptions,
detail.visitType ?? visitTypeOptions[0].value,
);
_modeIndex = _indexOf(
visitModeOptions,
detail.visitMode ?? visitModeOptions[0].value,
);
_priorityIndex = _indexOf(
visitPriorityOptions,
detail.priority ?? visitPriorityOptions[2].value,
);
_planTime = detail.planTime != null && detail.planTime!.isNotEmpty
? _dateOnly(detail.planTime!)
: todayStr();
_planEndTime = _dateOnly(detail.planEndTime ?? '');
_visitPerson.text = detail.visitPerson;
_visitPhone.text = detail.visitPhone ?? '';
_visitContent.text = detail.visitContent ?? '';
final index = _salesmen.indexWhere((s) => s.id == detail.salesmanId);
_salesmanIndex = index >= 0 ? index + 1 : -1;
// 编辑时若客户不在可选范围内(如已终止),补充一条占位以正确回显
if (detail.customerId != 0 &&
!_customers.any((c) => c.id == detail.customerId)) {
_customers = [
CustomerOption(
id: detail.customerId,
customerNo: detail.customer?.customerNo ?? '',
customerName: detail.customer?.customerName ?? '当前客户',
status: 0,
),
..._customers,
];
}
});
}
int _indexOf(List<ValueOption> options, int value) {
final index = options.indexWhere((o) => o.value == value);
return index < 0 ? 0 : index;
}
String _dateOnly(String value) =>
value.length >= 10 ? value.substring(0, 10) : value;
CustomerOption? get _selectedCustomer =>
_customers.where((c) => c.id == _customerId).firstOrNull;
String get _customerLabel {
final customer = _selectedCustomer;
if (customer == null) return '';
return '${customer.customerName}'
'${customer.customerNo.isNotEmpty ? ' · ${customer.customerNo}' : ''}';
}
List<String> get _customerLabels => _customers
.map(
(c) =>
'${c.customerName}${c.customerNo.isNotEmpty ? ' · ${c.customerNo}' : ''}',
)
.toList();
void _pickCustomer() {
final labels = _customerLabels;
if (labels.isEmpty) return;
final index = _customers.indexWhere((c) => c.id == _customerId);
_pickIndex(labels, index < 0 ? 0 : index, (i) {
setState(() => _customerId = _customers[i].id);
});
}
Future<void> _pickIndex(
List<String> labels,
int current,
ValueChanged<int> onSelected,
) async {
final index = await showModalBottomSheet<int>(
context: context,
backgroundColor: AppColors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.lg)),
),
builder: (ctx) => SafeArea(
child: ListView(
shrinkWrap: true,
children: [
for (var i = 0; i < labels.length; i++)
ListTile(
title: Text(
labels[i],
style: AppTypography.bodyMd.copyWith(color: AppColors.onSurface),
),
trailing: i == current
? const XIcon('check', size: 18, color: AppColors.primary)
: null,
onTap: () => Navigator.of(ctx).pop(i),
),
],
),
),
);
if (index != null) onSelected(index);
}
Future<void> _pickDate({required bool isStart}) async {
final initial = parseDate(isStart ? _planTime : _planEndTime) ?? DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: initial,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked == null) return;
setState(() {
if (isStart) {
_planTime = formatDate(picked);
} else {
_planEndTime = formatDate(picked);
}
});
}
Future<void> _onSave() async {
if (_customerId == null) {
showAppToast('请选择关联客户');
return;
}
if (_planTime.isEmpty) {
showAppToast('请选择计划开始日期');
return;
}
setState(() => _saving = true);
try {
final payload = <String, dynamic>{
'customerId': _customerId,
'subject': _subject.text,
'visitType': visitTypeOptions[_typeIndex].value,
'visitMode': visitModeOptions[_modeIndex].value,
'priority': visitPriorityOptions[_priorityIndex].value,
'planTime': _planTime,
'planEndTime': _planEndTime,
'visitPerson': _visitPerson.text,
'visitPhone': _visitPhone.text,
'visitContent': _visitContent.text,
};
final salesmanId =
_salesmanIndex > 0 ? _salesmen[_salesmanIndex - 1].id : null;
if (_isAdmin && salesmanId != null) payload['salesmanId'] = salesmanId;
final api = ref.read(visitApiProvider);
final res = _isEdit
? await api.update(widget.id!, payload)
: await api.create(payload);
if (!mounted) return;
if (res.isOk) {
showAppToast('保存成功', success: true);
await Future.delayed(const Duration(milliseconds: 500));
if (mounted) context.pop();
} else {
showAppToast(res.message.isNotEmpty ? res.message : '保存失败');
}
} catch (_) {
showAppToast('保存失败,请重试');
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final items = <Widget>[
_formItem(label: '关联客户', isRequired: true, child: _customerPicker()),
_formItem(
label: '拜访主题',
child: _input(_subject, '例如:配电房增容方案沟通'),
),
_formItem(
label: '拜访类型',
child: _optionPicker(
_typeLabels,
_typeIndex,
(i) => setState(() => _typeIndex = i),
),
),
_formItem(
label: '拜访方式',
child: _optionPicker(
_modeLabels,
_modeIndex,
(i) => setState(() => _modeIndex = i),
),
),
_formItem(
label: '优先级',
child: _optionPicker(
_priorityLabels,
_priorityIndex,
(i) => setState(() => _priorityIndex = i),
),
),
_formItem(
label: '计划开始',
isRequired: true,
child: _datePicker(
_planTime,
'请选择计划开始日期',
() => _pickDate(isStart: true),
),
),
_formItem(
label: '计划结束',
child: _datePicker(_planEndTime, '选填', () => _pickDate(isStart: false)),
),
_formItem(label: '拜访人', child: _input(_visitPerson, '默认当前登录用户')),
_formItem(
label: '联系电话',
child: _input(_visitPhone, '选填', number: true),
),
if (_isAdmin) _formItem(label: '负责业务员', child: _salesmanPicker()),
_formItem(
label: '计划说明',
child: _textarea(_visitContent, '本次拜访的目标与重点事项...'),
),
];
final children = <Widget>[];
for (var i = 0; i < items.length; i++) {
if (i > 0) {
children.add(
const Divider(height: 1, thickness: 1, color: AppColors.borderRule),
);
}
children.add(items[i]);
}
return XPageShell(
topBar: XTopBar.nav(
title: _isEdit ? '编辑拜访计划' : '新建拜访计划',
onBack: () => context.pop(),
),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 60),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
XCard(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 16),
child: Column(children: children),
),
XPrimaryButton(
label: '保存计划',
loading: _saving,
onPressed: _onSave,
margin: const EdgeInsets.fromLTRB(16, 24, 16, 40),
),
],
),
),
);
}
Widget _formItem({
required String label,
bool isRequired = false,
required Widget child,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: AppTypography.labelBold.copyWith(
color: AppColors.onSurfaceVariant,
),
),
if (isRequired)
Text(
' *',
style: AppTypography.labelBold.copyWith(
color: AppColors.statusError,
),
),
],
),
const SizedBox(height: 8),
child,
],
),
);
}
Widget _customerPicker() {
final label = _customerLabel;
final disabled = _isEdit || _customers.isEmpty;
return GestureDetector(
onTap: disabled ? null : _pickCustomer,
child: _pickerBox(
label.isNotEmpty
? label
: (_customers.isNotEmpty ? '请选择客户' : '暂无客户可选'),
label.isEmpty,
_isEdit
? null
: const XIcon('forward', size: 16, color: AppColors.outline),
),
);
}
Widget _optionPicker(
List<String> labels,
int index,
ValueChanged<int> onSelected,
) {
return GestureDetector(
onTap: () => _pickIndex(labels, index, onSelected),
child: _pickerBox(
labels[index],
false,
const XIcon('forward', size: 16, color: AppColors.outline),
),
);
}
Widget _datePicker(String value, String placeholder, VoidCallback onPick) {
return GestureDetector(
onTap: onPick,
child: _pickerBox(
value.isNotEmpty ? value : placeholder,
value.isEmpty,
const XIcon('calendar', size: 16, color: AppColors.outline),
),
);
}
Widget _salesmanPicker() {
final labels = ['默认本人', ..._salesmen.map((s) => s.optionLabel)];
final index = _salesmanIndex;
final text = index >= 0 && index < labels.length ? labels[index] : '默认本人';
return GestureDetector(
onTap: () => _pickIndex(labels, index < 0 ? 0 : index, (i) {
setState(() => _salesmanIndex = i);
}),
child: _pickerBox(
text,
index < 0,
const XIcon('forward', size: 16, color: AppColors.outline),
),
);
}
Widget _pickerBox(String text, bool placeholder, Widget? trailing) {
return Container(
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: _fieldDecoration,
child: Row(
children: [
Expanded(
child: Text(
text,
style: TextStyle(
fontSize: 15,
color: placeholder
? AppColors.textPlaceholder
: AppColors.onSurface,
),
),
),
?trailing,
],
),
);
}
Widget _input(
TextEditingController controller,
String hint, {
bool number = false,
}) {
return Container(
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: _fieldDecoration,
child: Row(
children: [
Expanded(
child: TextField(
controller: controller,
keyboardType: number ? TextInputType.number : TextInputType.text,
style: const TextStyle(fontSize: 15, color: AppColors.onSurface),
cursorColor: AppColors.primary,
decoration: _inputDecoration(hint),
),
),
],
),
);
}
Widget _textarea(TextEditingController controller, String hint) {
return Container(
height: 92,
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
decoration: _fieldDecoration,
child: TextField(
controller: controller,
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
style: const TextStyle(fontSize: 15, color: AppColors.onSurface),
cursorColor: AppColors.primary,
decoration: _inputDecoration(hint),
),
);
}
BoxDecoration get _fieldDecoration => BoxDecoration(
color: AppColors.surfaceBright,
border: Border.all(color: AppColors.borderRule),
borderRadius: BorderRadius.circular(10),
);
InputDecoration _inputDecoration(String hint) => InputDecoration(
isDense: true,
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
hintText: hint,
hintStyle: const TextStyle(fontSize: 15, color: AppColors.textPlaceholder),
);
}
@@ -0,0 +1,553 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/utils/format_util.dart';
import '../../../shared/widgets/app_feedback.dart';
import '../../../shared/widgets/x_card.dart';
import '../../../shared/widgets/x_chip.dart';
import '../../../shared/widgets/x_common.dart';
import '../../../shared/widgets/x_icon.dart';
import '../../../shared/widgets/x_top_bar.dart';
import '../data/visit_api.dart';
import '../data/visit_models.dart';
/// 拜访列表页,对应 `pages/visit/list.vue`。
class VisitListPage extends ConsumerStatefulWidget {
const VisitListPage({super.key});
@override
ConsumerState<VisitListPage> createState() => _VisitListPageState();
}
class _VisitListPageState extends ConsumerState<VisitListPage> {
static const int _pageSize = 10;
final ScrollController _scrollController = ScrollController();
final TextEditingController _keywordController = TextEditingController();
List<VisitInfo> _list = const [];
VisitStats _stats = VisitStats.empty;
int _total = 0;
int _page = 1;
bool _hasMore = true;
bool _loading = false;
String _keyword = '';
int? _filterStatus;
int? _filterPriority;
String _filterPlanDate = '';
Timer? _searchDebounce;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_reload();
}
@override
void dispose() {
_searchDebounce?.cancel();
_scrollController.dispose();
_keywordController.dispose();
super.dispose();
}
void _onScroll() {
final position = _scrollController.position;
if (position.pixels >= position.maxScrollExtent - 200) _loadMore();
}
Future<void> _loadStats() async {
try {
final res = await ref.read(visitApiProvider).stats();
if (res.isOk && res.data != null && mounted) {
setState(() => _stats = res.data!);
}
} catch (_) {
// 统计失败不阻塞列表
}
}
Future<void> _load({bool reset = false}) async {
if (_loading) return;
setState(() => _loading = true);
if (reset) {
_page = 1;
_hasMore = true;
}
try {
final res = await ref
.read(visitApiProvider)
.list(
page: _page,
pageSize: _pageSize,
keyword: _keyword.isEmpty ? null : _keyword,
status: _filterStatus,
priority: _filterPriority,
planStartDate: _filterPlanDate.isEmpty ? null : _filterPlanDate,
planEndDate: _filterPlanDate.isEmpty ? null : _filterPlanDate,
);
if (res.isOk && res.data != null && mounted) {
final data = res.data!;
setState(() {
_list = reset ? data.list : [..._list, ...data.list];
_total = data.total;
_hasMore = _list.length < data.total;
if (_hasMore) _page += 1;
});
}
} catch (_) {
showAppToast('加载拜访记录失败');
} finally {
if (mounted) setState(() => _loading = false);
}
}
void _reload() {
_loadStats();
_load(reset: true);
}
void _loadMore() {
if (_hasMore && !_loading) _load();
}
Future<void> _refresh() async {
await _loadStats();
await _load(reset: true);
}
void _onSearchChanged(String value) {
_keyword = value;
_searchDebounce?.cancel();
_searchDebounce = Timer(
const Duration(milliseconds: 350),
() => _load(reset: true),
);
}
void _setStatus(int? value) {
if (_filterStatus == value) return;
setState(() {
_filterStatus = value;
_filterPlanDate = '';
});
_load(reset: true);
}
void _setPriority(int? value) {
if (_filterPriority == value) return;
setState(() => _filterPriority = value);
_load(reset: true);
}
void _quickStatus(int status) {
setState(() {
_filterPlanDate = '';
_filterPriority = null;
_filterStatus = status;
});
_load(reset: true);
}
void _quickToday() {
setState(() {
_filterStatus = null;
_filterPriority = null;
_filterPlanDate = todayStr();
});
_load(reset: true);
}
void _quickPriority() {
setState(() {
_filterPlanDate = '';
_filterStatus = VisitStatus.pending;
_filterPriority = 1;
});
_load(reset: true);
}
String _findLabel(List<ValueOption> options, int? value) {
if (value == null) return '';
return options.where((o) => o.value == value).map((o) => o.label).firstOrNull ??
'';
}
@override
Widget build(BuildContext context) {
return XPageShell(
topBar: XTopBar.nav(
title: '客户拜访',
onBack: () => context.pop(),
trailing: GestureDetector(
onTap: () => context.push('/visit/batch'),
behavior: HitTestBehavior.opaque,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const XIcon('calendar', size: 20, color: AppColors.primary),
const SizedBox(width: 4),
Text(
'批量安排',
style: AppTypography.labelBold.copyWith(
color: AppColors.primary,
),
),
],
),
),
),
child: Stack(
children: [
RefreshIndicator(
onRefresh: _refresh,
color: AppColors.primary,
child: ListView(
controller: _scrollController,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 140),
children: [
_buildKpiGrid(),
const SizedBox(height: 14),
XSearchField(
controller: _keywordController,
hintText: '按单号、主题、拜访人搜索...',
onChanged: _onSearchChanged,
),
const SizedBox(height: 8),
_buildFilters(),
..._buildList(),
],
),
),
Positioned(right: 20, bottom: 40, child: _buildFab()),
],
),
);
}
Widget _buildKpiGrid() {
return LayoutBuilder(
builder: (context, constraints) {
final width = (constraints.maxWidth - 10) / 2;
return Wrap(
spacing: 10,
runSpacing: 10,
children: [
_kpiTile(
width,
'${_stats.pending}',
'待执行',
AppColors.primary,
() => _quickStatus(VisitStatus.pending),
),
_kpiTile(
width,
'${_stats.todayPlan}',
'今日计划',
AppColors.tertiary,
_quickToday,
),
_kpiTile(
width,
'${_stats.urgentPending}',
'加急待办',
AppColors.statusError,
_quickPriority,
),
_kpiTile(
width,
'${_stats.conversionRate}%',
'转化率',
AppColors.secondary,
null,
),
],
);
},
);
}
Widget _kpiTile(
double width,
String value,
String label,
Color valueColor,
VoidCallback? onTap,
) {
return GestureDetector(
onTap: onTap,
child: Container(
width: width,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: AppColors.surface,
border: Border.all(color: AppColors.borderRule),
borderRadius: BorderRadius.circular(AppRadius.lg),
boxShadow: const [
BoxShadow(color: Color(0x0A000000), blurRadius: 8, offset: Offset(0, 2)),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
value,
style: AppTypography.dataMono.copyWith(
fontSize: 22,
fontWeight: FontWeight.w700,
color: valueColor,
),
),
const SizedBox(height: 2),
Text(
label,
style: const TextStyle(fontSize: 12, color: AppColors.textMuted),
),
],
),
),
);
}
Widget _buildFilters() {
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_filterChip('全部', _filterStatus == null, () => _setStatus(null)),
for (final option in visitStatusOptions)
_filterChip(
option.label,
_filterStatus == option.value,
() => _setStatus(option.value),
),
],
),
),
),
Padding(
padding: const EdgeInsets.only(top: 6, bottom: 2),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_filterChip(
'全部优先级',
_filterPriority == null,
() => _setPriority(null),
),
for (final option in visitPriorityOptions)
_filterChip(
option.label,
_filterPriority == option.value,
() => _setPriority(option.value),
),
],
),
),
),
],
),
);
}
Widget _filterChip(String label, bool selected, VoidCallback onTap) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: XFilterChip(label: label, selected: selected, onTap: onTap),
);
}
List<Widget> _buildList() {
if (_loading && _list.isEmpty) {
return const [XPlaceholder(text: '加载中...')];
}
if (_list.isEmpty) {
return const [XPlaceholder(text: '暂无拜访记录')];
}
return [
for (final visit in _list) _buildCard(visit),
_buildFooter(),
];
}
Widget _buildCard(VisitInfo visit) {
final subject = visit.subject?.isNotEmpty == true
? visit.subject!
: (visit.customer?.customerName?.isNotEmpty == true
? visit.customer!.customerName!
: '未命名拜访');
final customerName = visit.customer?.customerName?.isNotEmpty == true
? visit.customer!.customerName!
: '未知客户';
final typeText = _findLabel(visitTypeOptions, visit.visitType);
return XCard(
clip: true,
margin: const EdgeInsets.only(top: 12),
onTap: () => context.push('/visit/detail/${visit.id}'),
child: Stack(
children: [
Positioned(
left: 0,
top: 0,
bottom: 0,
child: Container(width: 4, color: visitStatusBarColor(visit.status)),
),
Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
subject,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.onSurface,
),
),
),
const SizedBox(width: 8),
XChip(
visitStatusText(visit.status),
tone: visitStatusTone(visit.status),
),
],
),
const SizedBox(height: 8),
Row(
children: [
const XIcon('groups', size: 15, color: AppColors.textMuted),
const SizedBox(width: 6),
Text(
customerName,
style: const TextStyle(
fontSize: 14,
color: AppColors.onSurfaceVariant,
),
),
if (typeText.isNotEmpty) ...[
const SizedBox(width: 6),
XChip(typeText, tone: ChipTone.muted),
],
],
),
const SizedBox(height: 8),
Row(
children: [
_metaItem(
'schedule',
visit.planTime != null && visit.planTime!.isNotEmpty
? formatDateLabel(visit.planTime)
: '未排期',
),
const SizedBox(width: 14),
_metaItem(
'person',
visit.visitPerson.isNotEmpty ? visit.visitPerson : '-',
),
],
),
],
),
),
),
Container(
width: 92,
color: AppColors.surfaceBright,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
XChip(
visit.priority == null ? '' : visitPriorityText(visit.priority),
tone: visitPriorityTone(visit.priority),
),
const SizedBox(height: 8),
Text(
visit.visitNo,
style: AppTypography.dataMono.copyWith(
fontSize: 10,
color: AppColors.textMuted,
),
),
],
),
),
],
),
],
),
);
}
Widget _metaItem(String icon, String text) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
XIcon(icon, size: 15, color: AppColors.textMuted),
const SizedBox(width: 4),
Text(
text,
style: const TextStyle(fontSize: 13, color: AppColors.textMuted),
),
],
);
}
Widget _buildFooter() {
return Padding(
padding: const EdgeInsets.only(top: 16, bottom: 4),
child: Center(
child: Text(
_hasMore ? '加载中...' : '共 $_total 条',
style: const TextStyle(fontSize: 12, color: AppColors.textMuted),
),
),
);
}
Widget _buildFab() {
return GestureDetector(
onTap: () => context.push('/visit/form'),
child: Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(14),
boxShadow: const [
BoxShadow(color: Color(0x4D0054CD), blurRadius: 16, offset: Offset(0, 6)),
],
),
child: const Center(child: XIcon('add', size: 24, color: Colors.white)),
),
);
}
}