484 lines
20 KiB
Dart
484 lines
20 KiB
Dart
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<InvoiceDetailPage> createState() => _InvoiceDetailPageState();
|
||
}
|
||
|
||
class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
||
late TextEditingController _formalNameController;
|
||
late TextEditingController _notesController;
|
||
late List<InvoiceItem> _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<void> _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<ScrollStartNotification>(
|
||
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<void> _openPdf() async {
|
||
if (_currentFilePath != null) {
|
||
await OpenFilex.open(_currentFilePath!);
|
||
}
|
||
}
|
||
|
||
Future<void> _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」を押すと行が削除されます。
|
||
|
||
これで、実務でよくある「値引き」や「項目削除」といった操作も、アプリ内で完結できるようになりました。
|
||
ぜひ、色々と試してみてください!
|