240 lines
No EOL
9.9 KiB
Dart
240 lines
No EOL
9.9 KiB
Dart
// lib/screens/invoice_detail_page.dart
|
|
// version: 1.2.2 (Fix: Resolved Class name conflict strictly)
|
|
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 'package:printing/printing.dart';
|
|
import 'package:drift/drift.dart' as drift;
|
|
import '../models/invoice_models.dart'; // 本来の Invoice, InvoiceItem を使用
|
|
import '../services/pdf_generator.dart';
|
|
import '../data/database.dart' as db; // エイリアスを db に固定
|
|
import '../main.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;
|
|
|
|
@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();
|
|
super.dispose();
|
|
}
|
|
|
|
void _addItem() => setState(() => _items.add(InvoiceItem(description: "新項目", quantity: 1, unitPrice: 0)));
|
|
void _removeItem(int index) => setState(() => _items.removeAt(index));
|
|
|
|
void _pickFromMaster() {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (context) => FractionallySizedBox(
|
|
heightFactor: 0.9,
|
|
child: ProductPickerModal(
|
|
onItemSelected: (item) {
|
|
setState(() => _items.add(item));
|
|
Navigator.pop(context);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _saveChanges() async {
|
|
final String formalName = _formalNameController.text.trim();
|
|
if (formalName.isEmpty) return;
|
|
|
|
final updatedCustomer = _currentInvoice.customer.copyWith(formalName: formalName);
|
|
final updatedInvoice = _currentInvoice.copyWith(
|
|
customer: updatedCustomer,
|
|
items: _items,
|
|
notes: _notesController.text,
|
|
);
|
|
|
|
setState(() => _isEditing = false);
|
|
|
|
try {
|
|
final File pdfFile = await PdfGenerator.generateInvoicePdf(updatedInvoice);
|
|
final String newPath = pdfFile.path;
|
|
|
|
// DB側のクラスにはすべて `db.` を付ける
|
|
await database.into(database.customers).insertOnConflictUpdate(
|
|
db.CustomersCompanion.insert(
|
|
id: updatedCustomer.id,
|
|
displayName: updatedCustomer.displayName,
|
|
formalName: updatedCustomer.formalName,
|
|
address: drift.Value(updatedCustomer.address),
|
|
department: drift.Value(updatedCustomer.department),
|
|
lastUpdatedAt: drift.Value(DateTime.now()),
|
|
),
|
|
);
|
|
|
|
final invoiceCompanion = db.InvoicesCompanion.insert(
|
|
id: updatedInvoice.invoiceNumber,
|
|
customerId: updatedCustomer.id,
|
|
date: updatedInvoice.date,
|
|
type: "請求",
|
|
filePath: drift.Value(newPath),
|
|
notes: drift.Value(updatedInvoice.notes),
|
|
totalAmount: updatedInvoice.totalAmount,
|
|
);
|
|
|
|
// item の型を InvoiceItem と明示して getter エラーを回避
|
|
final List<db.InvoiceItemsCompanion> itemCompanions = _items.map((InvoiceItem item) => db.InvoiceItemsCompanion.insert(
|
|
invoiceId: updatedInvoice.invoiceNumber,
|
|
description: item.description,
|
|
quantity: item.quantity,
|
|
unitPrice: item.unitPrice,
|
|
)).toList();
|
|
|
|
await database.saveFullInvoice(invoiceCompanion, itemCompanions);
|
|
|
|
setState(() {
|
|
_currentInvoice = updatedInvoice.copyWith(filePath: newPath);
|
|
_currentFilePath = newPath;
|
|
});
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存完了')));
|
|
await Printing.layoutPdf(onLayout: (format) async => pdfFile.readAsBytesSync(), name: newPath.split('/').last);
|
|
} catch (e) {
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('エラー: $e'), backgroundColor: Colors.red));
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final amountFormatter = NumberFormat("#,###");
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text("請求書詳細"),
|
|
backgroundColor: Colors.blueGrey,
|
|
foregroundColor: Colors.white,
|
|
actions: [
|
|
if (!_isEditing)
|
|
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: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildHeaderSection(),
|
|
const Divider(height: 32),
|
|
_buildItemTable(amountFormatter),
|
|
if (_isEditing) _buildEditButtons(),
|
|
const SizedBox(height: 24),
|
|
_buildSummarySection(amountFormatter),
|
|
const SizedBox(height: 24),
|
|
_buildFooterActions(),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHeaderSection() {
|
|
final dateFormatter = DateFormat('yyyy年MM月dd日');
|
|
if (_isEditing) {
|
|
return Column(
|
|
children: [
|
|
TextField(controller: _formalNameController, decoration: const InputDecoration(labelText: "取引先 正式名称")),
|
|
const SizedBox(height: 12),
|
|
TextField(controller: _notesController, maxLines: 2, decoration: const InputDecoration(labelText: "備考")),
|
|
],
|
|
);
|
|
}
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text("${_currentInvoice.customer.formalName} 御中", style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
|
Text("請求番号: ${_currentInvoice.invoiceNumber}"),
|
|
Text("発行日: ${dateFormatter.format(_currentInvoice.date)}"),
|
|
],
|
|
);
|
|
}
|
|
|
|
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)},
|
|
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;
|
|
if (_isEditing) {
|
|
return TableRow(children: [
|
|
_EditableCell(initialValue: item.description, onChanged: (val) => item.description = val),
|
|
_EditableCell(initialValue: item.quantity.toString(), keyboardType: TextInputType.number, onChanged: (val) => setState(() => item.quantity = int.tryParse(val) ?? 0)),
|
|
_EditableCell(initialValue: item.unitPrice.toString(), keyboardType: TextInputType.number, onChanged: (val) => setState(() => item.unitPrice = int.tryParse(val) ?? 0)),
|
|
_TableCell(formatter.format(item.subtotal)),
|
|
IconButton(icon: const Icon(Icons.delete, color: Colors.red, size: 20), onPressed: () => _removeItem(idx)),
|
|
]);
|
|
}
|
|
return TableRow(children: [_TableCell(item.description), _TableCell(item.quantity.toString()), _TableCell(formatter.format(item.unitPrice)), _TableCell(formatter.format(item.subtotal)), const SizedBox()]);
|
|
}),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildEditButtons() => Row(children: [ElevatedButton(onPressed: _addItem, child: const Text("行追加")), const SizedBox(width: 8), ElevatedButton(onPressed: _pickFromMaster, child: const Text("マスター"))]);
|
|
|
|
Widget _buildSummarySection(NumberFormat formatter) {
|
|
// 確実に int で計算
|
|
int subtotal = _items.fold<int>(0, (int sum, InvoiceItem item) => sum + (item.quantity * item.unitPrice));
|
|
return Align(
|
|
alignment: Alignment.centerRight,
|
|
child: Column(children: [Text("合計: ¥${formatter.format((subtotal * 1.1).floor())}", style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold))]),
|
|
);
|
|
}
|
|
|
|
Widget _buildFooterActions() => (_isEditing || _currentFilePath == null) ? const SizedBox() : Row(children: [Expanded(child: ElevatedButton(onPressed: () => OpenFilex.open(_currentFilePath!), child: const Text("開く"))), const SizedBox(width: 12), Expanded(child: ElevatedButton(onPressed: () => Share.shareXFiles([XFile(_currentFilePath!)]), child: const Text("共有")))]);
|
|
}
|
|
|
|
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), child: TextField(controller: TextEditingController(text: initialValue), keyboardType: keyboardType, style: const TextStyle(fontSize: 12), onChanged: onChanged));
|
|
} |