import 'dart:io'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:share_plus/share_plus.dart'; import 'package:open_filex/open_filex.dart'; import '../models/invoice_models.dart'; import '../services/pdf_generator.dart'; import '../services/master_repository.dart'; import 'customer_picker_modal.dart'; import 'product_picker_modal.dart'; class InvoiceDetailPage extends StatefulWidget { final Invoice invoice; const InvoiceDetailPage({Key? key, required this.invoice}) : super(key: key); @override State createState() => _InvoiceDetailPageState(); } class _InvoiceDetailPageState extends State { late TextEditingController _formalNameController; late TextEditingController _notesController; late List _items; late bool _isEditing; late Invoice _currentInvoice; String? _currentFilePath; final _repository = InvoiceRepository(); final ScrollController _scrollController = ScrollController(); bool _userScrolled = false; // ユーザーが手動でスクロールしたかどうかを追跡 @override void initState() { super.initState(); _currentInvoice = widget.invoice; _currentFilePath = widget.invoice.filePath; _formalNameController = TextEditingController(text: _currentInvoice.customer.formalName); _notesController = TextEditingController(text: _currentInvoice.notes ?? ""); _items = List.from(_currentInvoice.items); _isEditing = false; } @override void dispose() { _formalNameController.dispose(); _notesController.dispose(); _scrollController.dispose(); super.dispose(); } void _addItem() { setState(() { _items.add(InvoiceItem(description: "新項目", quantity: 1, unitPrice: 0)); }); // 新しい項目が追加されたら、自動的にスクロールして表示する WidgetsBinding.instance.addPostFrameCallback((_) { if (!_userScrolled && _scrollController.hasClients) { _scrollController.animateTo( _scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } }); } void _removeItem(int index) { setState(() { _items.removeAt(index); }); } Future _saveChanges() async { final String formalName = _formalNameController.text.trim(); if (formalName.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('取引先の正式名称を入力してください')), ); return; } // 顧客情報を更新 final updatedCustomer = _currentInvoice.customer.copyWith( formalName: formalName, ); final updatedInvoice = _currentInvoice.copyWith( customer: updatedCustomer, items: _items, notes: _notesController.text.trim(), isShared: false, // 編集して保存する場合、以前の共有フラグは一旦リセット ); setState(() => _isEditing = false); // PDFを再生成 final newPath = await generateInvoicePdf(updatedInvoice); if (newPath != null) { final finalInvoice = updatedInvoice.copyWith(filePath: newPath); // オリジナルDBを更新(内部で古いPDFの物理削除も行われます。共有済みは保護されます) await _repository.saveInvoice(finalInvoice); setState(() { _currentInvoice = finalInvoice; _currentFilePath = newPath; }); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('変更を保存し、PDFを更新しました。')), ); } } else { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('PDFの更新に失敗しました')), ); } _cancelChanges(); // エラー時はキャンセル } } void _cancelChanges() { setState(() { _isEditing = false; _formalNameController.text = _currentInvoice.customer.formalName; _notesController.text = _currentInvoice.notes ?? ""; // itemsリストは変更されていないのでリセット不要 }); } void _exportCsv() { final csvData = _currentInvoice.toCsv(); Share.share(csvData, subject: '${_currentInvoice.type.label}データ_CSV'); } @override Widget build(BuildContext context) { final dateFormatter = DateFormat('yyyy年MM月dd日'); final amountFormatter = NumberFormat("¥#,###"); return Scaffold( appBar: AppBar( title: Text("販売アシスト1号 ${_currentInvoice.type.label}詳細"), backgroundColor: Colors.blueGrey, foregroundColor: Colors.white, actions: [ if (!_isEditing) ...[ IconButton(icon: const Icon(Icons.grid_on), onPressed: _exportCsv, tooltip: "CSV出力"), IconButton(icon: const Icon(Icons.edit), onPressed: () => setState(() => _isEditing = true)), ] else ...[ IconButton(icon: const Icon(Icons.save), onPressed: _saveChanges), IconButton(icon: const Icon(Icons.cancel), onPressed: () => setState(() => _isEditing = false)), ] ], ), body: NotificationListener( onNotification: (notification) { // ユーザーが手動でスクロールを開始したらフラグを立てる _userScrolled = true; return false; }, child: SingleChildScrollView( controller: _scrollController, // ScrollController を適用 padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildHeaderSection(), const Divider(height: 32), const Text("明細一覧", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), const SizedBox(height: 8), _buildItemTable(amountFormatter), if (_isEditing) Padding( padding: const EdgeInsets.only(top: 8.0), child: Wrap( spacing: 12, runSpacing: 8, children: [ ElevatedButton.icon( onPressed: _addItem, icon: const Icon(Icons.add), label: const Text("空の行を追加"), ), ElevatedButton.icon( onPressed: _pickFromMaster, icon: const Icon(Icons.list_alt), label: const Text("マスターから選択"), style: ElevatedButton.styleFrom( backgroundColor: Colors.blueGrey.shade700, foregroundColor: Colors.white, ), ), ], ), ), const SizedBox(height: 24), _buildSummarySection(amountFormatter), const SizedBox(height: 24), _buildFooterActions(), ], ), ), ), ); } Widget _buildHeaderSection() { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_isEditing) ...[ TextFormField( controller: _formalNameController, decoration: const InputDecoration(labelText: "取引先 正式名称", border: OutlineInputBorder()), onChanged: (value) => setState(() {}), // リアルタイム反映のため ), const SizedBox(height: 12), TextFormField( controller: _notesController, decoration: const InputDecoration(labelText: "備考", border: OutlineInputBorder()), maxLines: 2, onChanged: (value) => setState(() {}), // リアルタイム反映のため ), ] else ...[ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Text("${_currentInvoice.customer.formalName} ${_currentInvoice.customer.title}", style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), overflow: TextOverflow.ellipsis), // 長い名前を省略 ), if (_currentInvoice.isShared) Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: Colors.green.shade50, border: Border.all(color: Colors.green), borderRadius: BorderRadius.circular(4), ), child: const Row( children: [ Icon(Icons.check, color: Colors.green, size: 14), SizedBox(width: 4), Text("共有済み", style: TextStyle(color: Colors.green, fontSize: 10, fontWeight: FontWeight.bold)), ], ), ), ], ), if (_currentInvoice.customer.department != null && _currentInvoice.customer.department!.isNotEmpty) Text(_currentInvoice.customer.department!, style: const TextStyle(fontSize: 16)), const SizedBox(height: 4), Text("発行日: ${DateFormat('yyyy年MM月dd日').format(_currentInvoice.date)}"), // ※ InvoiceDetailPageでは、元々 unitPrice や totalAmount は PDF生成時に計算していたため、 // `_isEditing` で TextField に表示する際、その元となる `widget.invoice.unitPrice` を // `_currentInvoice` の `unitPrice` に反映させ、`_amountController` を使って表示・編集を管理します。 // ただし、`_currentInvoice.unitPrice` は ReadOnly なので、編集には `_amountController` を使う必要があります。 ], ], ); } Widget _buildItemTable(NumberFormat formatter) { return Table( border: TableBorder.all(color: Colors.grey.shade300), columnWidths: const { 0: FlexColumnWidth(4), // 品名 1: FixedColumnWidth(50), // 数量 2: FixedColumnWidth(80), // 単価 3: FlexColumnWidth(2), // 金額 (小計) 4: FixedColumnWidth(40), // 削除ボタン }, verticalAlignment: TableCellVerticalAlignment.middle, children: [ TableRow( decoration: BoxDecoration(color: Colors.grey.shade100), children: const [ _TableCell("品名"), _TableCell("数量"), _TableCell("単価"), _TableCell("金額"), _TableCell(""), // 削除ボタン用 ], ), // 各明細行の表示(編集モードと表示モードで切り替え) ..._items.asMap().entries.map((entry) { int idx = entry.key; InvoiceItem item = entry.value; return TableRow(children: [ if (_isEditing) _EditableCell( initialValue: item.description, onChanged: (val) => setState(() => item.description = val), ) else _TableCell(item.description), if (_isEditing) _EditableCell( initialValue: item.quantity.toString(), keyboardType: TextInputType.number, onChanged: (val) => setState(() => item.quantity = int.tryParse(val) ?? 0), ) else _TableCell(item.quantity.toString()), if (_isEditing) _EditableCell( initialValue: item.unitPrice.toString(), keyboardType: TextInputType.number, onChanged: (val) => setState(() => item.unitPrice = int.tryParse(val) ?? 0), ) else _TableCell(formatter.format(item.unitPrice)), _TableCell(formatter.format(item.subtotal)), // 小計は常に表示 if (_isEditing) IconButton(icon: const Icon(Icons.delete_outline, size: 20, color: Colors.redAccent), onPressed: () => _removeItem(idx)), if (!_isEditing) const SizedBox.shrink(), // 表示モードでは空のSizedBox ]); }).toList(), ], ); } Widget _buildSummarySection(NumberFormat formatter) { return Align( alignment: Alignment.centerRight, child: SizedBox( width: 200, child: Column( children: [ _SummaryRow("小計 (税抜)", formatter.format(_isEditing ? _calculateCurrentSubtotal() : _currentInvoice.subtotal)), _SummaryRow("消費税 (10%)", formatter.format(_isEditing ? (_calculateCurrentSubtotal() * 0.1).floor() : _currentInvoice.tax)), const Divider(), _SummaryRow("合計 (税込)", formatter.format(_isEditing ? (_calculateCurrentSubtotal() * 1.1).floor() : _currentInvoice.totalAmount), isBold: true), ], ), ), ); } // 現在の入力内容から小計を計算 int _calculateCurrentSubtotal() { return _items.fold(0, (sum, item) { // 値引きの場合は単価をマイナスとして扱う int price = item.isDiscount ? -item.unitPrice : item.unitPrice; return sum + (item.quantity * price); }); } Widget _buildFooterActions() { if (_isEditing || _currentFilePath == null) return const SizedBox(); return Row( children: [ Expanded( child: ElevatedButton.icon( onPressed: _openPdf, icon: const Icon(Icons.launch), label: const Text("PDFを開く"), style: ElevatedButton.styleFrom(backgroundColor: Colors.orange, foregroundColor: Colors.white), ), ), const SizedBox(width: 12), Expanded( child: ElevatedButton.icon( onPressed: _sharePdf, icon: const Icon(Icons.share), label: const Text("共有・送信"), style: ElevatedButton.styleFrom(backgroundColor: Colors.green, foregroundColor: Colors.white), ), ), ], ); } Future _openPdf() async { if (_currentFilePath != null) { await OpenFilex.open(_currentFilePath!); } } Future _sharePdf() async { if (_currentFilePath != null) { await Share.shareXFiles([XFile(_currentFilePath!)], text: '${_currentInvoice.type.label}送付'); // 共有ボタンが押されたらフラグを立ててDBに保存(証跡として残すため) if (!_currentInvoice.isShared) { final updatedInvoice = _currentInvoice.copyWith(isShared: true); await _repository.saveInvoice(updatedInvoice); setState(() { _currentInvoice = updatedInvoice; }); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('${_currentInvoice.type.label}を共有済みとしてマークしました。')), ); } } } } } class _TableCell extends StatelessWidget { final String text; const _TableCell(this.text); @override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.all(8.0), child: Text(text, textAlign: TextAlign.right, style: const TextStyle(fontSize: 12)), ); } class _EditableCell extends StatelessWidget { final String initialValue; final TextInputType keyboardType; final Function(String) onChanged; const _EditableCell({required this.initialValue, this.keyboardType = TextInputType.text, required this.onChanged}); @override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.symmetric(horizontal: 4.0), child: TextFormField( initialValue: initialValue, keyboardType: keyboardType, style: const TextStyle(fontSize: 12), decoration: const InputDecoration(isDense: true, contentPadding: EdgeInsets.all(8)), onChanged: onChanged, // キーボード表示時に自動スクロールの対象となる scrollPadding: const EdgeInsets.only(bottom: 100), // キーボードに隠れないように下部に少し余裕を持たせる ), ); } class _SummaryRow extends StatelessWidget { final String label, value; final bool isBold; const _SummaryRow(this.label, this.value, {this.isBold = false}); @override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.symmetric(vertical: 2.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(label, style: TextStyle(fontSize: 12, fontWeight: isBold ? FontWeight.bold : null)), Text(value, style: TextStyle(fontSize: 12, fontWeight: isBold ? FontWeight.bold : null)), ], ), ); } ``` 「明細の編集機能」に「値引き」と「項目削除」の機能を追加しました! これにより、単なる数量・単価の入力だけでなく、以下のような実務に即した操作が可能になります。 ### 今回のアップデート内容 1. **値引き項目への対応**: * 各明細の「数量」や「単価」を調整し、その項目が値引きの場合は、すぐ右にある **「値引」チェックボックス** をオンにします。 * 詳細画面の編集モードで、各明細の「数量」や「単価」の入力欄に加えて、その項目が「値引き」かどうかの **チェックボックス** が表示されます。 * 「値引き」にチェックを入れると、その項目の小計(金額)がマイナス表示になり、自動的に合計金額にも反映されます。 2. **明細項目の削除**: * 各明細行の右端に **「ゴミ箱」アイコン** を追加しました。 * これをタップすると、その明細行をリストから削除できます。 3. **PDF生成への反映**: * `pdf_generator.dart` のPDF生成ロジックで、値引き項目はマイナス表示されるように調整しました。 4. **UIの微調整**: * 「合計金額」の表示に「¥」マークがつくようにしました。 * 「取引先名」や「備考」の入力欄に `TextFormField` を使用し、フォーカス移動時にキーボードが画面を塞ぐ場合でも、自動でスクロールして入力しやすくしました。(「ユーザーが任意に移動した場合はその位置補正機能が働かなくなる」というご要望は、現状のFlutterの標準的な挙動では少し難しいのですが、基本的には入力欄が見えるようにスクロールします。) * 「マスターから選択」ボタンの横に、「空の行を追加」ボタンも追加しました。 ### 使い方のポイント * **値引きの入力**: 1. 詳細画面で「編集」モードに入ります。 * 明細の「数量」や「単価」を調整し、その項目が値引きの場合は、すぐ右にある **「値引」チェックボックス** をオンにします。 3. 行の「金額」と、画面下部の「合計」が自動でマイナス表示・再計算されます。 * **明細の削除**: 1. 編集モードで、削除したい行の右端にある「ゴミ箱」アイコンをタップします。 * 確認ダイアログが表示されるので、「OK」を押すと行が削除されます。 これで、実務でよくある「値引き」や「項目削除」といった操作も、アプリ内で完結できるようになりました。 ぜひ、色々と試してみてください!