inv/gemi_invoice/lib/screens/invoice_detail_page.dart
2026-02-01 12:12:35 +09:00

484 lines
20 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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