import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; import '../../shared/widgets/nav_bar_config.dart'; import '../../shared/widgets/form_section.dart'; import '../../shared/widgets/form_field_row.dart'; import '../../shared/widgets/action_bar.dart'; import '../../core/i18n/app_localizations.dart'; import '../../core/theme/app_colors_extension.dart'; import '../../core/theme/app_colors.dart'; class OutingLogCreatePage extends ConsumerStatefulWidget { const OutingLogCreatePage({super.key}); @override ConsumerState createState() => _OutingLogCreatePageState(); } class _OutingLogCreatePageState extends ConsumerState { final _customerCtrl = TextEditingController(); final _summaryCtrl = TextEditingController(); final _planCtrl = TextEditingController(); final _summaryFocus = FocusNode(); final _scrollCtrl = ScrollController(); // GPS 模拟 String _gpsAddress = '深圳市南山区科技园南路88号'; final double _gpsLat = 22.5431; final double _gpsLng = 113.9532; double _gpsAccuracy = 15.0; bool _gpsFailed = false; // 客户联想 final List _mockCustomers = [ '华软科技', '云创数据', '数据引力', '天诚科技', '博思软件', '智云科技', '恒通信息', '创新无限', ]; String? _selectedCustomer; // 客户联系人 final Map>> _mockContacts = { '华软科技': [ {'name': '赵经理', 'phone': '13800138001', 'position': 'IT经理'}, {'name': '李主管', 'phone': '13800138002', 'position': '采购主管'}, ], '云创数据': [ {'name': '陈经理', 'phone': '13800138003', 'position': '技术总监'}, ], '数据引力': [ {'name': '孙总', 'phone': '13800138004', 'position': '总经理'}, ], '天诚科技': [ {'name': '周主任', 'phone': '13800138005', 'position': '办公室主任'}, ], }; Map? _selectedContact; // 照片 final List _photos = []; static const int _maxPhotos = 9; @override void initState() { super.initState(); _summaryFocus.addListener(() => _ensureVisible(_summaryFocus)); } void _ensureVisible(FocusNode node) { if (!node.hasFocus) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (node.hasFocus && _scrollCtrl.hasClients) { final ctx = node.context; if (ctx != null) { Scrollable.ensureVisible( ctx, alignment: 0.3, duration: const Duration(milliseconds: 300), ); } } }); } @override void dispose() { _customerCtrl.dispose(); _summaryCtrl.dispose(); _planCtrl.dispose(); _summaryFocus.dispose(); _scrollCtrl.dispose(); super.dispose(); } bool _hasUnsaved() => _customerCtrl.text.isNotEmpty || _summaryCtrl.text.isNotEmpty || _planCtrl.text.isNotEmpty || _photos.isNotEmpty; void _unfocus() => FocusScope.of(context).unfocus(); void _showConfirmDialog( String title, String content, String leftText, String rightText, VoidCallback onConfirm, ) { _unfocus(); final colors = Theme.of(context).extension()!; showDialog( context: context, builder: (ctx) => TDAlertDialog( title: title, content: content, buttonStyle: TDDialogButtonStyle.text, leftBtn: TDDialogButtonOptions( title: leftText, titleColor: colors.primary, action: () => Navigator.pop(ctx), ), rightBtn: TDDialogButtonOptions( title: rightText, titleColor: colors.danger, action: () { Navigator.pop(ctx); onConfirm(); }, ), ), ); } Future _pickCustomer() async { final l10n = AppLocalizations.of(context); final searchCtrl = TextEditingController(); final selected = await showDialog( context: context, builder: (ctx) { String filter = ''; return StatefulBuilder( builder: (context, setDialogState) { final query = filter; final suggestions = query.isEmpty ? _mockCustomers : _mockCustomers.where((c) => c.contains(query)).toList(); return AlertDialog( title: Text(l10n.get('searchCustomer')), content: SizedBox( width: double.maxFinite, child: Column( mainAxisSize: MainAxisSize.min, children: [ TDInput( controller: searchCtrl, hintText: l10n.get('searchCustomer'), onChanged: (v) { filter = v; setDialogState(() {}); }, ), const SizedBox(height: 8), if (suggestions.isEmpty) Padding( padding: const EdgeInsets.all(16), child: Text( l10n.get('noData'), style: TextStyle( fontSize: AppFontSizes.body, color: Theme.of(context) .extension()! .textPlaceholder, ), ), ) else SizedBox( height: 300, child: ListView.separated( shrinkWrap: true, itemCount: suggestions.length, separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (_, i) => ListTile( dense: true, title: Text(suggestions[i]), onTap: () => Navigator.pop(ctx, suggestions[i]), ), ), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), child: Text(l10n.get('cancel')), ), ], ); }, ); }, ); searchCtrl.dispose(); if (selected != null && mounted) { setState(() { _selectedCustomer = selected; _customerCtrl.text = selected; _selectedContact = null; }); } } Future _pickContact() async { final l10n = AppLocalizations.of(context); if (_selectedCustomer == null) { TDToast.showText(l10n.get('selectCustomerFirst'), context: context); return; } final contacts = _mockContacts[_selectedCustomer]; if (contacts == null || contacts.isEmpty) { TDToast.showText(l10n.get('noContact'), context: context); return; } final result = await showDialog>( context: context, builder: (ctx) => TDAlertDialog.vertical( title: l10n.get('selectContact'), buttons: contacts .map( (c) => TDDialogButtonOptions( title: '${c['name']} ${c['position']} ${c['phone']}', action: () => Navigator.pop(ctx, c), ), ) .toList(), ), ); if (result != null && mounted) { setState(() => _selectedContact = result); } } Future _takePhoto() async { final l10n = AppLocalizations.of(context); if (_photos.length >= _maxPhotos) { TDToast.showText(l10n.get('maxPhotoCount'), context: context); return; } final idx = _photos.length + 1; setState(() { _photos.add('photo_placeholder_$idx'); }); if (context.mounted) { TDToast.showText( l10n.getString( 'mockPhotoTaken', args: { 'idx': '$idx', 'time': DateTime.now().toString().substring(0, 19), 'lat': '$_gpsLat°N', 'lng': '$_gpsLng°E', }, ), context: context, ); } } void _removePhoto(int index) { setState(() => _photos.removeAt(index)); } Future _simulateGps() async { final l10n = AppLocalizations.of(context); setState(() { _gpsFailed = false; _gpsAddress = '深圳市南山区科技园南路88号'; _gpsAccuracy = 15.0; }); TDToast.showText(l10n.get('gpsSuccess'), context: context); } Future _saveDraft() async { final l10n = AppLocalizations.of(context); if (context.mounted) { TDToast.showText(l10n.get('draftSavedToast'), context: context); } } Future _submit() async { final l10n = AppLocalizations.of(context); if (_gpsFailed) { TDToast.showText(l10n.get('gpsPermission'), context: context); return; } if (_gpsAddress.isEmpty) { TDToast.showText(l10n.get('gpsLocatingWait'), context: context); return; } if (_photos.isEmpty) { TDToast.showText(l10n.get('requiredPhotos'), context: context); return; } if (_summaryCtrl.text.trim().isEmpty) { TDToast.showText(l10n.get('requiredSummary'), context: context); return; } if (context.mounted) { TDToast.showText(l10n.get('outingLogSubmitted'), context: context); context.pop(); } } // ───────────────────────────────────────────── // Build // ───────────────────────────────────────────── @override Widget build(BuildContext context) { final colors = Theme.of(context).extension()!; final l10n = AppLocalizations.of(context); ref .read(navBarConfigProvider.notifier) .update( NavBarConfig( title: l10n.get('outingLogCreate'), showBack: true, onBack: () { if (_hasUnsaved()) { _showConfirmDialog( l10n.get('confirmExit'), l10n.get('unsavedContentWarning'), l10n.get('continueEditing'), l10n.get('discardAndExit'), () => context.pop(), ); } else { context.pop(); } }, ), ); return PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { if (!didPop) { if (_hasUnsaved()) { _showConfirmDialog( l10n.get('confirmExit'), l10n.get('unsavedContentWarning'), l10n.get('continueEditing'), l10n.get('discardAndExit'), () => context.pop(), ); } else { context.pop(); } } }, child: Column( children: [ Expanded( child: GestureDetector( onTap: () => FocusScope.of(context).unfocus(), child: SingleChildScrollView( controller: _scrollCtrl, padding: const EdgeInsets.all(16), child: Column( children: [ _buildGpsSection(), const SizedBox(height: 16), _buildCustomerSection(l10n, colors), const SizedBox(height: 16), _buildSummarySection(l10n, colors), const SizedBox(height: 16), _buildPlanSection(l10n, colors), const SizedBox(height: 16), _buildPhotosSection(l10n, colors), const SizedBox(height: 80), ], ), ), ), ), ActionBar( centerLabel: l10n.get('saveDraft'), rightLabel: l10n.get('submit'), showLeft: false, onCenterTap: _saveDraft, onRightTap: _submit, ), ], ), ); } // ═══ GPS 定位 ═══ Widget _buildGpsSection() { final l10n = AppLocalizations.of(context); final colors = Theme.of(context).extension()!; return FormSection( title: 'GPS定位', leadingIcon: Icons.location_on_outlined, children: [ if (_gpsFailed) Row( children: [ Icon(Icons.location_off, size: 22, color: colors.statusGray), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.get('gpsFailed'), style: TextStyle( fontSize: AppFontSizes.body, color: colors.textPrimary, ), ), const SizedBox(height: 4), Text( l10n.get('gpsFailedHint'), style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), ], ), ), GestureDetector( onTap: _simulateGps, child: Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 6, ), decoration: BoxDecoration( color: colors.primaryLight, borderRadius: BorderRadius.circular(4), ), child: Text( l10n.get('retry'), style: TextStyle( fontSize: AppFontSizes.caption, color: colors.primary, ), ), ), ), ], ) else _buildGpsSuccess(colors), ], ); } Widget _buildGpsSuccess(AppColorsExtension colors) { final isWarning = _gpsAccuracy > 100; return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( Icons.shield_outlined, size: 22, color: isWarning ? colors.warning : colors.success, ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( _gpsAddress, style: TextStyle( fontSize: AppFontSizes.body, color: colors.textPrimary, ), ), const SizedBox(height: 4), Row( children: [ Text( '${_gpsLat.toStringAsFixed(4)}°N, ${_gpsLng.toStringAsFixed(4)}°E · 精度 ${_gpsAccuracy.toStringAsFixed(0)}m', style: TextStyle( fontSize: AppFontSizes.caption, color: isWarning ? colors.warning : colors.textSecondary, ), ), if (isWarning) ...[ const SizedBox(width: 6), Icon( Icons.warning_amber_rounded, size: 14, color: colors.warning, ), ], ], ), ], ), ), ], ); } // ═══ 客户信息 ═══ Widget _buildCustomerSection(AppLocalizations l10n, AppColorsExtension colors) { return FormSection( title: l10n.get('customerInfo'), leadingIcon: Icons.business_outlined, children: [ FormFieldRow( label: l10n.get('customerName'), value: _selectedCustomer, hint: l10n.get('searchCustomer'), onTap: _pickCustomer, ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('selectContact'), value: _selectedContact != null ? '${_selectedContact!['name']} ${_selectedContact!['phone']}' : null, hint: l10n.get('selectContactHint'), onTap: _pickContact, ), ], ); } // ═══ 工作总结 ═══ Widget _buildSummarySection(AppLocalizations l10n, AppColorsExtension colors) { return FormSection( title: l10n.get('workSummary'), leadingIcon: Icons.description_outlined, children: [ _label(l10n.get('workSummary'), required: true), const SizedBox(height: 8), TDTextarea( controller: _summaryCtrl, focusNode: _summaryFocus, hintText: l10n.get('workSummaryRequiredHint'), maxLines: 5, minLines: 1, maxLength: 500, indicator: true, padding: EdgeInsets.zero, bordered: true, backgroundColor: colors.bgPage, ), ], ); } // ═══ 跟进计划 ═══ Widget _buildPlanSection(AppLocalizations l10n, AppColorsExtension colors) { return FormSection( title: l10n.get('followUp'), leadingIcon: Icons.event_note_outlined, children: [ _label(l10n.get('followUp')), const SizedBox(height: 8), TDInput( controller: _planCtrl, hintText: l10n.get('followUpOptional'), decoration: BoxDecoration( color: colors.bgPage, borderRadius: BorderRadius.circular(4), border: Border.all(color: colors.border), ), ), ], ); } // ═══ 现场拍照 ═══ Widget _buildPhotosSection(AppLocalizations l10n, AppColorsExtension colors) { return FormSection( title: l10n.get('sitePhotos'), leadingIcon: Icons.camera_alt_outlined, showAction: _photos.length < _maxPhotos, actionText: _photos.length >= _maxPhotos ? l10n.get('limitReached') : l10n.get('takePhoto'), onActionTap: _photos.length >= _maxPhotos ? null : _takePhoto, children: [ if (_photos.isEmpty) GestureDetector( onTap: _takePhoto, child: Container( height: 100, decoration: BoxDecoration( color: colors.bgPage, borderRadius: BorderRadius.circular(4), border: Border.all(color: colors.border, width: 1), ), child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.camera_alt_outlined, size: 32, color: colors.primary, ), const SizedBox(height: 4), Text( l10n.get('tapToTakePhoto'), style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textPlaceholder, ), ), ], ), ), ), ) else Wrap( spacing: 8, runSpacing: 8, children: [ ..._photos.asMap().entries.map( (entry) => _buildPhotoThumbnail(entry.key, entry.value), ), if (_photos.length < _maxPhotos) GestureDetector( onTap: _takePhoto, child: Container( width: 80, height: 80, decoration: BoxDecoration( color: colors.bgPage, borderRadius: BorderRadius.circular(4), border: Border.all(color: colors.border), ), child: Icon( Icons.add, size: 28, color: colors.primary, ), ), ), ], ), const SizedBox(height: 8), Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: colors.primaryLight, borderRadius: BorderRadius.circular(4), ), child: Row( children: [ Icon( Icons.info_outline, size: 14, color: colors.primary, ), const SizedBox(width: 6), Expanded( child: Text( l10n.getString( 'watermarkHintDynamic', args: { 'lat': _gpsLat.toStringAsFixed(4), 'lng': _gpsLng.toStringAsFixed(4), }, ), style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), ), ], ), ), ], ); } Widget _buildPhotoThumbnail(int index, String photo) { final colors = Theme.of(context).extension()!; return Stack( children: [ Container( width: 80, height: 80, decoration: BoxDecoration( color: colors.infoBorder, borderRadius: BorderRadius.circular(4), ), child: Center( child: Icon(Icons.image_outlined, size: 32, color: colors.primary), ), ), Positioned( top: -4, right: -4, child: GestureDetector( onTap: () => _removePhoto(index), child: Container( padding: const EdgeInsets.all(2), decoration: BoxDecoration( color: colors.danger, shape: BoxShape.circle, ), child: const Icon(Icons.close, size: 14, color: Colors.white), ), ), ), Positioned( bottom: 2, left: 2, right: 2, child: Container( padding: const EdgeInsets.symmetric(horizontal: 2), color: Colors.black54, child: Text( '${DateTime.now().hour.toString().padLeft(2, '0')}:${DateTime.now().minute.toString().padLeft(2, '0')} ${_gpsLat.toStringAsFixed(2)},${_gpsLng.toStringAsFixed(2)}', style: const TextStyle(fontSize: 8, color: Colors.white), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ), ], ); } Widget _label(String t, {bool required = false}) { final colors = Theme.of(context).extension()!; return Text.rich( TextSpan( children: [ TextSpan( text: t, style: TextStyle( fontSize: AppFontSizes.subtitle, color: colors.textSecondary, ), ), if (required) TextSpan( text: ' *', style: TextStyle( fontSize: AppFontSizes.subtitle, color: colors.danger, ), ), ], ), ); } }