import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import '../models/customer_model.dart'; import '../models/invoice_models.dart'; import '../services/pdf_generator.dart'; import '../services/invoice_repository.dart'; import '../services/customer_repository.dart'; import '../widgets/invoice_pdf_preview_page.dart'; import '../services/gps_service.dart'; import 'customer_master_screen.dart'; import 'product_master_screen.dart'; import '../models/product_model.dart'; import '../services/app_settings_repository.dart'; import '../services/edit_log_repository.dart'; class InvoiceInputForm extends StatefulWidget { final Function(Invoice invoice, String filePath) onInvoiceGenerated; final Invoice? existingInvoice; // 追加: 編集時の既存伝票 final DocumentType initialDocumentType; final bool startViewMode; final bool showNewBadge; final bool showCopyBadge; const InvoiceInputForm({ super.key, required this.onInvoiceGenerated, this.existingInvoice, // 追加 this.initialDocumentType = DocumentType.invoice, this.startViewMode = true, this.showNewBadge = false, this.showCopyBadge = false, }); @override State createState() => _InvoiceInputFormState(); } List _cloneItems(List source, {bool resetIds = false}) { return source .map((e) => InvoiceItem( id: resetIds ? null : e.id, productId: e.productId, description: e.description, quantity: e.quantity, unitPrice: e.unitPrice, )) .toList(growable: true); } class _InvoiceInputFormState extends State { final _repository = InvoiceRepository(); final InvoiceRepository _invoiceRepo = InvoiceRepository(); Customer? _selectedCustomer; final List _items = []; double _taxRate = 0.10; bool _includeTax = false; DocumentType _documentType = DocumentType.invoice; // 追加 DateTime _selectedDate = DateTime.now(); // 追加: 伝票日付 bool _isDraft = true; // デフォルトは下書き final TextEditingController _subjectController = TextEditingController(); // 追加 bool _isSaving = false; // 保存中フラグ String? _currentId; // 保存対象のID(コピー時に新規になる) bool _isLocked = false; final List<_InvoiceSnapshot> _undoStack = []; final List<_InvoiceSnapshot> _redoStack = []; bool _isApplyingSnapshot = false; bool get _canUndo => _undoStack.length > 1; bool get _canRedo => _redoStack.isNotEmpty; bool _isViewMode = true; // デフォルトでビューワ bool _summaryIsBlue = false; // デフォルトは白 final AppSettingsRepository _settingsRepo = AppSettingsRepository(); bool _showNewBadge = false; bool _showCopyBadge = false; final EditLogRepository _editLogRepo = EditLogRepository(); List _editLogs = []; final FocusNode _subjectFocusNode = FocusNode(); String _lastLoggedSubject = ""; bool _hasUnsavedChanges = false; String _documentTypeLabel(DocumentType type) { switch (type) { case DocumentType.estimation: return "見積書"; case DocumentType.delivery: return "納品書"; case DocumentType.invoice: return "請求書"; case DocumentType.receipt: return "領収書"; } } Color _documentTypeColor(DocumentType type) { switch (type) { case DocumentType.estimation: return Colors.blue; case DocumentType.delivery: return Colors.teal; case DocumentType.invoice: return Colors.indigo; case DocumentType.receipt: return Colors.green; } } String _customerNameWithHonorific(Customer customer) { final base = customer.formalName; final hasHonorific = RegExp(r'(様|御中|殿)$').hasMatch(base); return hasHonorific ? base : "$base ${customer.title}"; } String _ensureCurrentId() { _currentId ??= DateTime.now().millisecondsSinceEpoch.toString(); return _currentId!; } Future _confirmDiscardChanges() async { if (!_hasUnsavedChanges || _isSaving) { return true; } final result = await showDialog( context: context, barrierDismissible: false, builder: (context) => AlertDialog( title: const Text('保存されていない変更があります'), content: const Text('編集した内容を破棄してもよろしいですか?'), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('編集を続ける'), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), child: const Text('破棄する'), ), ], ), ); return result ?? false; } Future _handleBackPressed() async { final allow = await _confirmDiscardChanges(); if (!mounted || !allow) return; Navigator.of(context).maybePop(); } Future _pickCopyDocumentType() { return showDialog( context: context, builder: (context) => SimpleDialog( title: const Text('コピー後の伝票種別を選択'), children: DocumentType.values.map((type) { final isCurrent = type == _documentType; return SimpleDialogOption( onPressed: () => Navigator.of(context).pop(type), child: Row( children: [ Icon( isCurrent ? Icons.check_circle : Icons.circle_outlined, color: _documentTypeColor(type), ), const SizedBox(width: 12), Text(_documentTypeLabel(type)), ], ), ); }).toList(), ), ); } Future _copyAsNew() async { if (widget.existingInvoice == null && _currentId == null) return; final selectedType = await _pickCopyDocumentType(); if (selectedType == null) return; final clonedItems = _cloneItems(_items, resetIds: true); setState(() { _currentId = DateTime.now().millisecondsSinceEpoch.toString(); _isDraft = true; _isLocked = false; _selectedDate = DateTime.now(); _documentType = selectedType; _items ..clear() ..addAll(clonedItems); _isViewMode = false; _showCopyBadge = true; _showNewBadge = false; _pushHistory(clearRedo: true); _editLogs.clear(); }); } @override void initState() { super.initState(); _subjectController.addListener(_onSubjectChanged); _subjectFocusNode.addListener(() { if (!_subjectFocusNode.hasFocus) { final current = _subjectController.text; if (current != _lastLoggedSubject) { final id = _ensureCurrentId(); final msg = "件名を『$current』に更新しました"; _editLogRepo.addLog(id, msg).then((_) => _loadEditLogs()); _lastLoggedSubject = current; } } }); _subjectController.addListener(_onSubjectChanged); _loadInitialData(); } Future _loadInitialData() async { _repository.cleanupOrphanedPdfs(); final customerRepo = CustomerRepository(); await customerRepo.getAllCustomers(); final savedSummary = await _settingsRepo.getSummaryTheme(); _summaryIsBlue = savedSummary == 'blue'; _isApplyingSnapshot = true; setState(() { // 既存伝票がある場合は初期値を上書き if (widget.existingInvoice != null) { final inv = widget.existingInvoice!; _selectedCustomer = inv.customer; _items.addAll(inv.items); _taxRate = inv.taxRate; _includeTax = inv.taxRate > 0; _documentType = inv.documentType; _selectedDate = inv.date; _isDraft = inv.isDraft; _currentId = inv.id; _isLocked = inv.isLocked; if (inv.subject != null) _subjectController.text = inv.subject!; } else { _taxRate = 0; _includeTax = false; _isDraft = true; _documentType = widget.initialDocumentType; _currentId = null; _isLocked = false; } }); _isApplyingSnapshot = false; _isViewMode = widget.startViewMode; // 指定に従う _showNewBadge = widget.showNewBadge; _showCopyBadge = widget.showCopyBadge; _pushHistory(clearRedo: true, markDirty: false); if (_hasUnsavedChanges) { setState(() => _hasUnsavedChanges = false); } _lastLoggedSubject = _subjectController.text; if (_currentId != null) { _loadEditLogs(); } } @override void dispose() { _subjectFocusNode.dispose(); super.dispose(); } Future _loadEditLogs() async { if (_currentId == null) return; final logs = await _editLogRepo.getLogs(_currentId!); if (!mounted) return; setState(() => _editLogs = logs); } void _onSubjectChanged() { if (_isApplyingSnapshot) return; _pushHistory(); } void _addItem() { Navigator.push( context, MaterialPageRoute(builder: (_) => const ProductMasterScreen(selectionMode: true)), ).then((product) { if (product == null) return; setState(() { _items.add(InvoiceItem( productId: product.id, description: product.name, quantity: 1, unitPrice: product.defaultUnitPrice, )); }); _pushHistory(); final id = _ensureCurrentId(); final msg = "商品「${product.name}」を追加しました"; _editLogRepo.addLog(id, msg).then((_) => _loadEditLogs()); }); } int get _subTotal => _items.fold(0, (sum, item) => sum + (item.unitPrice * item.quantity)); Future _saveInvoice({bool generatePdf = true}) async { if (_selectedCustomer == null) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("取引先を選択してください"))); return; } if (_items.isEmpty) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("明細を1件以上入力してください"))); return; } // GPS情報の取得 final gpsService = GpsService(); final pos = await gpsService.getCurrentLocation(); if (pos != null) { await gpsService.logLocation(); // 履歴テーブルにも保存 } final invoiceId = _ensureCurrentId(); final invoice = Invoice( id: invoiceId, customer: _selectedCustomer!, date: _selectedDate, items: _items, taxRate: _includeTax ? _taxRate : 0.0, documentType: _documentType, customerFormalNameSnapshot: _selectedCustomer!.formalName, subject: _subjectController.text.isNotEmpty ? _subjectController.text : null, // 追加 notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : null, latitude: pos?.latitude, longitude: pos?.longitude, isDraft: _isDraft, // 追加 ); setState(() => _isSaving = true); try { // PDF生成有無に関わらず、まずは保存 if (generatePdf) { final path = await generateInvoicePdf(invoice); if (path != null) { final updatedInvoice = invoice.copyWith(filePath: path); await _repository.saveInvoice(updatedInvoice); _currentId = updatedInvoice.id; if (mounted) widget.onInvoiceGenerated(updatedInvoice, path); if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("伝票を保存し、PDFを生成しました"))); } else { if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("PDF生成に失敗しました"))); } } else { await _repository.saveInvoice(invoice); _currentId = invoice.id; if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("伝票を保存しました(PDF未生成)"))); } await _editLogRepo.addLog(_currentId!, "伝票を保存しました"); await _loadEditLogs(); if (mounted) { setState(() { _isViewMode = true; _hasUnsavedChanges = false; }); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存に失敗しました: $e'))); } } finally { if (mounted) setState(() => _isSaving = false); } } void _showPreview() { if (_selectedCustomer == null) return; final id = _ensureCurrentId(); _editLogRepo.addLog(id, "PDFプレビューを開きました").then((_) => _loadEditLogs()); final invoice = Invoice( id: id, customer: _selectedCustomer!, date: _selectedDate, // 修正 items: _items, taxRate: _includeTax ? _taxRate : 0.0, documentType: _documentType, customerFormalNameSnapshot: _selectedCustomer!.formalName, notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : "(非課税)", isDraft: _isDraft, isLocked: _isLocked, ); Navigator.push( context, MaterialPageRoute( builder: (context) => InvoicePdfPreviewPage( invoice: invoice, isUnlocked: true, isLocked: _isLocked, allowFormalIssue: invoice.isDraft && !_isLocked, onFormalIssue: invoice.isDraft ? () async { final promoted = invoice.copyWith(id: id, isDraft: false, isLocked: true); await _invoiceRepo.saveInvoice(promoted); final newPath = await generateInvoicePdf(promoted); final saved = newPath != null ? promoted.copyWith(filePath: newPath) : promoted; await _invoiceRepo.saveInvoice(saved); await _editLogRepo.addLog(_ensureCurrentId(), "正式発行しました"); if (!context.mounted) return false; setState(() { _isDraft = false; _isLocked = true; }); return true; } : null, showShare: true, showEmail: true, showPrint: true, ), ), ); } void _pushHistory({bool clearRedo = false, bool markDirty = true}) { setState(() { if (_undoStack.length >= 30) _undoStack.removeAt(0); _undoStack.add(_InvoiceSnapshot( customer: _selectedCustomer, items: _cloneItems(_items), taxRate: _taxRate, includeTax: _includeTax, documentType: _documentType, date: _selectedDate, isDraft: _isDraft, subject: _subjectController.text, )); if (clearRedo) _redoStack.clear(); if (markDirty) { _hasUnsavedChanges = true; } }); } void _undo() { if (_undoStack.length <= 1) return; // 直前状態がない setState(() { // 現在の状態をredoへ積む _redoStack.add(_InvoiceSnapshot( customer: _selectedCustomer, items: _cloneItems(_items), taxRate: _taxRate, includeTax: _includeTax, documentType: _documentType, date: _selectedDate, isDraft: _isDraft, subject: _subjectController.text, )); // 一番新しい履歴を捨て、直前のスナップショットを適用 _undoStack.removeLast(); final snapshot = _undoStack.last; _isApplyingSnapshot = true; _selectedCustomer = snapshot.customer; _items ..clear() ..addAll(_cloneItems(snapshot.items)); _taxRate = snapshot.taxRate; _includeTax = snapshot.includeTax; _documentType = snapshot.documentType; _selectedDate = snapshot.date; _isDraft = snapshot.isDraft; _subjectController.text = snapshot.subject; _isApplyingSnapshot = false; }); } void _redo() { if (_redoStack.isEmpty) return; setState(() { _undoStack.add(_InvoiceSnapshot( customer: _selectedCustomer, items: _cloneItems(_items), taxRate: _taxRate, includeTax: _includeTax, documentType: _documentType, date: _selectedDate, isDraft: _isDraft, subject: _subjectController.text, )); final snapshot = _redoStack.removeLast(); _isApplyingSnapshot = true; _selectedCustomer = snapshot.customer; _items ..clear() ..addAll(_cloneItems(snapshot.items)); _taxRate = snapshot.taxRate; _includeTax = snapshot.includeTax; _documentType = snapshot.documentType; _selectedDate = snapshot.date; _isDraft = snapshot.isDraft; _subjectController.text = snapshot.subject; _isApplyingSnapshot = false; }); } @override Widget build(BuildContext context) { final fmt = NumberFormat("#,###"); final themeColor = Theme.of(context).scaffoldBackgroundColor; final textColor = Colors.black87; final docColor = _documentTypeColor(_documentType); return PopScope( canPop: false, onPopInvokedWithResult: (didPop, result) async { if (didPop) return; final allow = await _confirmDiscardChanges(); if (allow && context.mounted) { Navigator.of(context).pop(result); } }, child: Scaffold( backgroundColor: themeColor, resizeToAvoidBottomInset: false, appBar: AppBar( backgroundColor: docColor, leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => _handleBackPressed(), ), title: Text("A1:${_documentTypeLabel(_documentType)}"), actions: [ if (_isDraft) Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), child: _DraftBadge(), ), IconButton( icon: const Icon(Icons.copy), tooltip: "コピーして新規", onPressed: () => _copyAsNew(), ), if (_isLocked) const Padding( padding: EdgeInsets.symmetric(horizontal: 8), child: Icon(Icons.lock, color: Colors.white), ) else if (_isViewMode) IconButton( icon: const Icon(Icons.edit), tooltip: "編集モードにする", onPressed: () => setState(() => _isViewMode = false), ) else ...[ IconButton( icon: const Icon(Icons.undo), onPressed: _canUndo ? _undo : null, tooltip: "元に戻す", ), IconButton( icon: const Icon(Icons.redo), onPressed: _canRedo ? _redo : null, tooltip: "やり直す", ), if (!_isLocked) IconButton( icon: const Icon(Icons.save), tooltip: "保存", onPressed: _isSaving ? null : () => _saveInvoice(generatePdf: false), ), ], ], ), body: Stack( children: [ Column( children: [ Expanded( child: SingleChildScrollView( padding: EdgeInsets.fromLTRB(16, 16, 16, MediaQuery.of(context).viewInsets.bottom + 140), keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildDateSection(), const SizedBox(height: 16), _buildCustomerSection(), const SizedBox(height: 16), _buildSubjectSection(textColor), const SizedBox(height: 20), _buildItemsSection(fmt), const SizedBox(height: 20), _buildSummarySection(fmt), const SizedBox(height: 12), _buildEditLogsSection(), const SizedBox(height: 20), ], ), ), ), _buildBottomActionBar(), ], ), if (_isSaving) Container( color: Colors.black54, child: const Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(color: Colors.white), SizedBox(height: 16), Text("保存中...", style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)), ], ), ), ), ], ), ), ); } Widget _buildDateSection() { final fmt = DateFormat('yyyy/MM/dd'); return GestureDetector( onTap: _isViewMode ? null : () async { final picked = await showDatePicker( context: context, initialDate: _selectedDate, firstDate: DateTime(2000), lastDate: DateTime(2100), ); if (picked != null) { setState(() => _selectedDate = picked); _pushHistory(); } }, child: Align( alignment: Alignment.centerLeft, child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 8, offset: const Offset(0, 3))], ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.calendar_today, size: 18, color: Colors.indigo), const SizedBox(width: 8), Text("伝票日付: ${fmt.format(_selectedDate)}", style: const TextStyle(fontWeight: FontWeight.bold)), if (_showNewBadge) Container( margin: const EdgeInsets.only(left: 8), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: Colors.orange.shade100, borderRadius: BorderRadius.circular(10), ), child: const Text("新規", style: TextStyle(color: Colors.deepOrange, fontSize: 11, fontWeight: FontWeight.bold)), ), if (_showCopyBadge) Container( margin: const EdgeInsets.only(left: 8), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: Colors.blue.shade100, borderRadius: BorderRadius.circular(10), ), child: const Text("複写", style: TextStyle(color: Colors.blue, fontSize: 11, fontWeight: FontWeight.bold)), ), if (!_isViewMode && !_isLocked) ...[ const SizedBox(width: 8), const Icon(Icons.chevron_right, size: 18, color: Colors.indigo), ], ], ), ), ), ); } Widget _buildCustomerSection() { return Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 8, offset: const Offset(0, 3))], ), child: ListTile( leading: const Icon(Icons.business, color: Colors.blueGrey), title: Text( _selectedCustomer != null ? _customerNameWithHonorific(_selectedCustomer!) : "取引先を選択してください", style: TextStyle(color: _selectedCustomer == null ? Colors.grey : Colors.black87, fontWeight: FontWeight.bold)), subtitle: _isViewMode ? null : const Text("顧客マスターから選択"), trailing: (_isViewMode || _isLocked) ? null : const Icon(Icons.chevron_right), onTap: (_isViewMode || _isLocked) ? null : () async { final Customer? picked = await Navigator.push( context, MaterialPageRoute( builder: (_) => CustomerMasterScreen(selectionMode: true), fullscreenDialog: true, ), ); if (picked != null) { setState(() => _selectedCustomer = picked); _pushHistory(); } }, ), ); } Widget _buildItemsSection(NumberFormat fmt) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text("明細項目", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), if (!_isViewMode && !_isLocked) TextButton.icon(onPressed: _addItem, icon: const Icon(Icons.add), label: const Text("追加")), ], ), if (_items.isEmpty) const Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Center(child: Text("商品が追加されていません", style: TextStyle(color: Colors.grey))), ) else if (_isViewMode) Column( children: _items .map((item) => Card( margin: const EdgeInsets.only(bottom: 6), elevation: 0.5, child: ListTile( dense: true, contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), title: Text(item.description, style: const TextStyle(fontSize: 13.5)), subtitle: Text("¥${fmt.format(item.unitPrice)} x ${item.quantity}", style: const TextStyle(fontSize: 12.5)), trailing: Text( "¥${fmt.format(item.unitPrice * item.quantity)}", style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13.5), ), ), )) .toList(), ) else ReorderableListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: _items.length, onReorder: (oldIndex, newIndex) async { int targetIndex = newIndex; setState(() { if (targetIndex > oldIndex) targetIndex -= 1; final item = _items.removeAt(oldIndex); _items.insert(targetIndex, item); }); _pushHistory(); final id = _ensureCurrentId(); final item = _items[targetIndex]; final msg = "明細を並べ替えました: ${item.description} を ${oldIndex + 1} → ${targetIndex + 1}"; await _editLogRepo.addLog(id, msg); await _loadEditLogs(); }, buildDefaultDragHandles: false, itemBuilder: (context, idx) { final item = _items[idx]; return ReorderableDelayedDragStartListener( key: ValueKey('item_${idx}_${item.description}'), index: idx, child: Card( margin: const EdgeInsets.only(bottom: 6), elevation: 0.5, child: ListTile( dense: true, contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), title: Text(item.description, style: const TextStyle(fontSize: 13.5)), subtitle: Text("¥${fmt.format(item.unitPrice)} x ${item.quantity}", style: const TextStyle(fontSize: 12.5)), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ if (!_isViewMode && !_isLocked) ...[ IconButton( icon: const Icon(Icons.remove, size: 18), onPressed: () async { if (item.quantity <= 1) return; setState(() => _items[idx] = item.copyWith(quantity: item.quantity - 1)); _pushHistory(); final id = _ensureCurrentId(); final msg = "${item.description} の数量を ${item.quantity - 1} に変更しました"; await _editLogRepo.addLog(id, msg); await _loadEditLogs(); }, constraints: const BoxConstraints.tightFor(width: 28, height: 28), padding: EdgeInsets.zero, ), Text('${item.quantity}', style: const TextStyle(fontSize: 12.5)), IconButton( icon: const Icon(Icons.add, size: 18), onPressed: () async { setState(() => _items[idx] = item.copyWith(quantity: item.quantity + 1)); _pushHistory(); final id = _ensureCurrentId(); final msg = "${item.description} の数量を ${item.quantity + 1} に変更しました"; await _editLogRepo.addLog(id, msg); await _loadEditLogs(); }, constraints: const BoxConstraints.tightFor(width: 28, height: 28), padding: EdgeInsets.zero, ), ], Text("¥${fmt.format(item.unitPrice * item.quantity)}", style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13.5)), const SizedBox(width: 6), IconButton( icon: const Icon(Icons.remove_circle_outline, color: Colors.redAccent, size: 18), onPressed: () async { final removed = _items[idx]; setState(() => _items.removeAt(idx)); _pushHistory(); final id = _ensureCurrentId(); final msg = "商品「${removed.description}」を削除しました"; await _editLogRepo.addLog(id, msg); await _loadEditLogs(); }, tooltip: "削除", constraints: const BoxConstraints.tightFor(width: 32, height: 32), padding: EdgeInsets.zero, ), ], ), onTap: () async { if (_isViewMode || _isLocked) return; final messenger = ScaffoldMessenger.of(context); final product = await Navigator.push( context, MaterialPageRoute(builder: (_) => const ProductMasterScreen(selectionMode: true)), ); if (product != null) { if (!mounted) return; final prevDesc = item.description; setState(() { _items[idx] = item.copyWith( productId: product.id, description: product.name, unitPrice: product.defaultUnitPrice, ); }); _pushHistory(); final id = _ensureCurrentId(); final msg = "商品を $prevDesc から ${product.name} に変更しました"; await _editLogRepo.addLog(id, msg); await _loadEditLogs(); if (!mounted) return; messenger.showSnackBar(SnackBar(content: Text(msg))); } }, ), ), ); }, ), ], ); } Widget _buildSummarySection(NumberFormat formatter) { final int subtotal = _subTotal; final int tax = _includeTax ? (subtotal * _taxRate).floor() : 0; final int total = subtotal + tax; final useBlue = _summaryIsBlue; final bgColor = useBlue ? Colors.indigo : Colors.white; final borderColor = Colors.transparent; final labelColor = useBlue ? Colors.white70 : Colors.black87; final totalColor = useBlue ? Colors.white : Colors.black87; final dividerColor = useBlue ? Colors.white24 : Colors.grey.shade300; return GestureDetector( onLongPress: () async { final selected = await showModalBottomSheet( context: context, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), builder: (context) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: const Icon(Icons.palette, color: Colors.indigo), title: const Text('インディゴ'), onTap: () => Navigator.pop(context, 'blue'), ), ListTile( leading: const Icon(Icons.palette, color: Colors.grey), title: const Text('白'), onTap: () => Navigator.pop(context, 'white'), ), ], ), ), ); if (selected == null) return; setState(() => _summaryIsBlue = selected == 'blue'); await _settingsRepo.setSummaryTheme(selected); }, child: Container( width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: bgColor, borderRadius: BorderRadius.circular(12), border: Border.all(color: borderColor), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildSummaryRow("小計", "¥${formatter.format(subtotal)}", labelColor), if (tax > 0) ...[ Divider(color: dividerColor), _buildSummaryRow("消費税", "¥${formatter.format(tax)}", labelColor), ], Divider(color: dividerColor), _buildSummaryRow( tax > 0 ? "合計金額 (税込)" : "合計金額", "¥${formatter.format(total)}", totalColor, isTotal: true, ), ], ), ), ); } Widget _buildSummaryRow(String label, String value, Color textColor, {bool isTotal = false}) { return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( label, style: TextStyle( fontSize: isTotal ? 16 : 14, fontWeight: isTotal ? FontWeight.w600 : FontWeight.normal, color: textColor, ), ), Text( value, style: TextStyle( fontSize: isTotal ? 18 : 14, fontWeight: FontWeight.w600, color: isTotal ? Colors.white : textColor, ), ), ], ), ); } Widget _buildBottomActionBar() { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), decoration: BoxDecoration( color: Colors.white, boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10, offset: const Offset(0, -5))], ), child: SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ Row( children: [ Expanded( child: OutlinedButton.icon( onPressed: _items.isEmpty ? null : _showPreview, icon: const Icon(Icons.picture_as_pdf), // アイコン変更 label: const Text("PDFプレビュー"), // 名称変更 style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16), side: const BorderSide(color: Colors.indigo), ), ), ), const SizedBox(width: 8), Expanded( child: _isLocked ? ElevatedButton.icon( onPressed: null, icon: const Icon(Icons.lock), label: const Text("ロック中"), ) : (_isViewMode ? ElevatedButton.icon( onPressed: () => setState(() => _isViewMode = false), icon: const Icon(Icons.edit), label: const Text("編集"), style: ElevatedButton.styleFrom( backgroundColor: Colors.indigo, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 16), ), ) : ElevatedButton.icon( onPressed: () => _saveInvoice(generatePdf: false), icon: const Icon(Icons.save), label: const Text("保存"), style: ElevatedButton.styleFrom( backgroundColor: Colors.indigo, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 16), ), )), ), ], ), ], ), ), ); } Widget _buildSubjectSection(Color textColor) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text("案件名 / 件名", style: TextStyle(fontWeight: FontWeight.bold, color: textColor)), const SizedBox(height: 8), Container( width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 8, offset: const Offset(0, 3))], ), child: TextField( focusNode: _subjectFocusNode, controller: _subjectController, style: TextStyle(color: textColor), readOnly: _isViewMode || _isLocked, enableInteractiveSelection: !(_isViewMode || _isLocked), decoration: InputDecoration( hintText: "例:事務所改修工事 / 〇〇月分リース料", hintStyle: TextStyle(color: textColor.withAlpha((0.5 * 255).round())), border: InputBorder.none, isDense: true, contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), ), ), ), ], ); } Widget _buildEditLogsSection() { if (_currentId == null) { return const SizedBox.shrink(); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Card( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), elevation: 0.5, child: Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), boxShadow: const [ BoxShadow( color: Color(0x22000000), blurRadius: 10, spreadRadius: -4, offset: Offset(0, 2), blurStyle: BlurStyle.inner, ), ], ), padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text("編集ログ (直近1週間)", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), const SizedBox(height: 8), if (_editLogs.isEmpty) const Text("編集ログはありません", style: TextStyle(color: Colors.grey, fontSize: 12)) else ..._editLogs.map((e) => Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Icon(Icons.circle, size: 6, color: Colors.grey), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( DateFormat('yyyy/MM/dd HH:mm').format(e.createdAt), style: const TextStyle(fontSize: 11, color: Colors.black54), ), Text( e.message, style: const TextStyle(fontSize: 13), ), ], ), ), ], ), )), ], ), ), ), ], ); } } class _DraftBadge extends StatelessWidget { const _DraftBadge(); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: Colors.orange.shade100, borderRadius: BorderRadius.circular(10), ), child: const Text( '下書き', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Colors.orange), ), ); } } class _InvoiceSnapshot { final Customer? customer; final List items; final double taxRate; final bool includeTax; final DocumentType documentType; final DateTime date; final bool isDraft; final String subject; _InvoiceSnapshot({ required this.customer, required this.items, required this.taxRate, required this.includeTax, required this.documentType, required this.date, required this.isDraft, required this.subject, }); }