872 lines
31 KiB
Dart
872 lines
31 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:share_plus/share_plus.dart';
|
|
import 'package:open_filex/open_filex.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'invoice_input_screen.dart';
|
|
import '../widgets/invoice_pdf_preview_page.dart';
|
|
import '../models/invoice_models.dart';
|
|
import '../services/pdf_generator.dart';
|
|
import '../services/invoice_repository.dart';
|
|
import '../services/customer_repository.dart';
|
|
import '../services/company_repository.dart';
|
|
import 'product_picker_modal.dart';
|
|
import '../models/company_model.dart';
|
|
import '../widgets/keyboard_inset_wrapper.dart';
|
|
|
|
class _DetailSnapshot {
|
|
final String formalName;
|
|
final String notes;
|
|
final List<InvoiceItem> items;
|
|
final double taxRate;
|
|
final bool includeTax;
|
|
final bool isDraft;
|
|
|
|
const _DetailSnapshot({
|
|
required this.formalName,
|
|
required this.notes,
|
|
required this.items,
|
|
required this.taxRate,
|
|
required this.includeTax,
|
|
required this.isDraft,
|
|
});
|
|
}
|
|
|
|
List<InvoiceItem> _cloneItemsDetail(List<InvoiceItem> source) {
|
|
return source
|
|
.map((e) => InvoiceItem(
|
|
id: e.id,
|
|
productId: e.productId,
|
|
description: e.description,
|
|
quantity: e.quantity,
|
|
unitPrice: e.unitPrice,
|
|
))
|
|
.toList(growable: true);
|
|
}
|
|
|
|
class InvoiceDetailPage extends StatefulWidget {
|
|
final Invoice invoice;
|
|
final bool editable;
|
|
final bool isUnlocked;
|
|
|
|
const InvoiceDetailPage({super.key, required this.invoice, this.editable = true, this.isUnlocked = true});
|
|
|
|
@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;
|
|
late double _taxRate; // 追加
|
|
late bool _includeTax; // 追加
|
|
String? _currentFilePath;
|
|
final _invoiceRepo = InvoiceRepository();
|
|
final _customerRepo = CustomerRepository();
|
|
final _companyRepo = CompanyRepository();
|
|
CompanyInfo? _companyInfo;
|
|
bool _showFormalWarning = true;
|
|
final List<_DetailSnapshot> _undoStack = [];
|
|
final List<_DetailSnapshot> _redoStack = [];
|
|
bool _isApplyingSnapshot = 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);
|
|
_taxRate = _currentInvoice.taxRate; // 初期化
|
|
_includeTax = _currentInvoice.taxRate > 0; // 初期化
|
|
_isEditing = false;
|
|
_loadCompanyInfo();
|
|
}
|
|
|
|
Future<void> _loadCompanyInfo() async {
|
|
final info = await _companyRepo.getCompanyInfo();
|
|
setState(() => _companyInfo = info);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_formalNameController.dispose();
|
|
_notesController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _addItem() {
|
|
setState(() {
|
|
_items.add(InvoiceItem(description: "新項目", quantity: 1, unitPrice: 0));
|
|
});
|
|
_pushHistory();
|
|
}
|
|
|
|
void _removeItem(int index) {
|
|
setState(() {
|
|
_items.removeAt(index);
|
|
});
|
|
_pushHistory();
|
|
}
|
|
|
|
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) {
|
|
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,
|
|
taxRate: _includeTax ? _taxRate : 0.0, // 更新
|
|
);
|
|
|
|
// データベースに保存
|
|
await _invoiceRepo.saveInvoice(updatedInvoice);
|
|
|
|
// 顧客の正式名称が変更されている可能性があるため、マスターも更新
|
|
if (updatedCustomer.formalName != widget.invoice.customer.formalName) {
|
|
await _customerRepo.saveCustomer(updatedCustomer);
|
|
}
|
|
|
|
setState(() => _isEditing = false);
|
|
_undoStack.clear();
|
|
_redoStack.clear();
|
|
|
|
final newPath = await generateInvoicePdf(updatedInvoice);
|
|
if (newPath != null) {
|
|
final finalInvoice = updatedInvoice.copyWith(filePath: newPath);
|
|
await _invoiceRepo.saveInvoice(finalInvoice); // パスを更新して再保存
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
_currentInvoice = finalInvoice;
|
|
_currentFilePath = newPath;
|
|
});
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('データベースとPDFを更新しました')),
|
|
);
|
|
}
|
|
}
|
|
|
|
void _exportCsv() {
|
|
final csvData = _currentInvoice.toCsv();
|
|
SharePlus.instance.share(ShareParams(text: csvData, subject: '請求書データ_CSV'));
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final fmt = NumberFormat("#,###");
|
|
final isDraft = _currentInvoice.isDraft;
|
|
final docTypeName = _currentInvoice.documentTypeName;
|
|
final themeColor = Colors.white; // 常に明色
|
|
final textColor = Colors.black87;
|
|
|
|
final locked = _currentInvoice.isLocked;
|
|
|
|
return Scaffold(
|
|
backgroundColor: themeColor,
|
|
resizeToAvoidBottomInset: false,
|
|
appBar: AppBar(
|
|
leading: const BackButton(), // 常に表示
|
|
title: const Text("A3:伝票詳細"),
|
|
backgroundColor: Colors.indigo.shade700,
|
|
actions: [
|
|
if (locked)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
|
child: Chip(
|
|
label: const Text("確定済み", style: TextStyle(color: Colors.white)),
|
|
avatar: const Icon(Icons.lock, size: 16, color: Colors.white),
|
|
backgroundColor: Colors.redAccent,
|
|
),
|
|
),
|
|
if (!_isEditing) ...[
|
|
IconButton(icon: const Icon(Icons.grid_on), onPressed: _exportCsv, tooltip: "CSV出力"),
|
|
if (widget.isUnlocked && !locked)
|
|
IconButton(
|
|
icon: const Icon(Icons.copy),
|
|
tooltip: "コピーして新規作成",
|
|
onPressed: () async {
|
|
final newId = DateTime.now().millisecondsSinceEpoch.toString();
|
|
final duplicateInvoice = _currentInvoice.copyWith(
|
|
id: newId,
|
|
date: DateTime.now(),
|
|
isDraft: true,
|
|
);
|
|
await Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => InvoiceInputForm(
|
|
onInvoiceGenerated: (inv, path) {},
|
|
existingInvoice: duplicateInvoice,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.edit_note, color: Colors.white),
|
|
tooltip: locked
|
|
? "ロック中"
|
|
: (widget.isUnlocked ? "詳細編集" : "アンロックして編集"),
|
|
onPressed: (locked || !widget.isUnlocked)
|
|
? null
|
|
: () async {
|
|
_pushHistory(clearRedo: true);
|
|
await Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => InvoiceInputForm(
|
|
onInvoiceGenerated: (inv, path) {},
|
|
existingInvoice: _currentInvoice,
|
|
),
|
|
),
|
|
);
|
|
final repo = InvoiceRepository();
|
|
final customerRepo = CustomerRepository();
|
|
final customers = await customerRepo.getAllCustomers();
|
|
final updated = (await repo.getAllInvoices(customers)).firstWhere((i) => i.id == _currentInvoice.id, orElse: () => _currentInvoice);
|
|
setState(() => _currentInvoice = updated);
|
|
},
|
|
),
|
|
] else ...[
|
|
IconButton(
|
|
icon: const Icon(Icons.undo),
|
|
onPressed: _undoStack.isNotEmpty ? _undo : null,
|
|
tooltip: "元に戻す",
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.redo),
|
|
onPressed: _redoStack.isNotEmpty ? _redo : null,
|
|
tooltip: "やり直す",
|
|
),
|
|
IconButton(icon: const Icon(Icons.save), onPressed: _saveChanges),
|
|
IconButton(icon: const Icon(Icons.cancel), onPressed: () => setState(() => _isEditing = false)),
|
|
]
|
|
],
|
|
),
|
|
body: KeyboardInsetWrapper(
|
|
basePadding: const EdgeInsets.all(16.0),
|
|
extraBottom: 48,
|
|
child: SingleChildScrollView(
|
|
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (isDraft)
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(10),
|
|
margin: const EdgeInsets.only(bottom: 8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.indigo.shade800,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: Colors.indigo.shade900),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.edit_note, color: Colors.white70),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
"下書き: 未確定・PDFは正式発行で確定",
|
|
style: const TextStyle(color: Colors.white70),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: Colors.orange.shade600,
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: Text(
|
|
"下書${docTypeName}",
|
|
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
_buildHeaderSection(textColor),
|
|
if (_isEditing) ...[
|
|
const SizedBox(height: 16),
|
|
_buildDraftToggleEdit(), // 編集用トグル
|
|
const SizedBox(height: 16),
|
|
_buildExperimentalSection(isDraft),
|
|
],
|
|
Divider(height: 32, color: Colors.grey.shade400),
|
|
Text("明細一覧", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: textColor)),
|
|
const SizedBox(height: 8),
|
|
_buildItemTable(fmt, textColor, isDraft),
|
|
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(fmt, textColor, isDraft),
|
|
const SizedBox(height: 24),
|
|
_buildFooterActions(),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHeaderSection(Color textColor) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (_isEditing) ...[
|
|
TextField(
|
|
controller: _formalNameController,
|
|
onChanged: (_) => _pushHistory(),
|
|
decoration: const InputDecoration(labelText: "取引先 正式名称", border: OutlineInputBorder()),
|
|
style: TextStyle(color: textColor),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: _notesController,
|
|
onChanged: (_) => _pushHistory(),
|
|
maxLines: 2,
|
|
decoration: const InputDecoration(labelText: "備考", border: OutlineInputBorder()),
|
|
style: TextStyle(color: textColor),
|
|
),
|
|
] else ...[
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
"伝票番号: ${_currentInvoice.invoiceNumber}",
|
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: textColor),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
"日付: ${DateFormat('yyyy/MM/dd').format(_currentInvoice.date)}",
|
|
style: TextStyle(color: textColor.withAlpha((0.8 * 255).round())),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey.shade100,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text("取引先", style: TextStyle(fontWeight: FontWeight.bold, color: textColor)),
|
|
const SizedBox(height: 4),
|
|
Text("${_currentInvoice.customerNameForDisplay} ${_currentInvoice.customer.title}",
|
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: textColor)),
|
|
if (_currentInvoice.subject?.isNotEmpty ?? false) ...[
|
|
const SizedBox(height: 6),
|
|
Text("件名: ${_currentInvoice.subject}",
|
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.indigo)),
|
|
],
|
|
if (_currentInvoice.customer.department != null && _currentInvoice.customer.department!.isNotEmpty)
|
|
Text(_currentInvoice.customer.department!, style: TextStyle(fontSize: 14, color: textColor)),
|
|
if ((_currentInvoice.contactAddressSnapshot ?? _currentInvoice.customer.address) != null)
|
|
Text("住所: ${_currentInvoice.contactAddressSnapshot ?? _currentInvoice.customer.address}", style: TextStyle(color: textColor)),
|
|
if ((_currentInvoice.contactTelSnapshot ?? _currentInvoice.customer.tel) != null)
|
|
Text("TEL: ${_currentInvoice.contactTelSnapshot ?? _currentInvoice.customer.tel}", style: TextStyle(color: textColor)),
|
|
if ((_currentInvoice.contactEmailSnapshot ?? _currentInvoice.customer.email) != null)
|
|
Text("メール: ${_currentInvoice.contactEmailSnapshot ?? _currentInvoice.customer.email}", style: TextStyle(color: textColor)),
|
|
if (_currentInvoice.notes?.isNotEmpty ?? false) ...[
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
"備考: ${_currentInvoice.notes}",
|
|
style: TextStyle(color: textColor.withAlpha((0.9 * 255).round())),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildItemTable(NumberFormat formatter, Color textColor, bool isDraft) {
|
|
return Table(
|
|
border: TableBorder.all(color: isDraft ? Colors.white24 : Colors.grey.shade300),
|
|
columnWidths: const {
|
|
0: FlexColumnWidth(4),
|
|
1: FixedColumnWidth(50),
|
|
2: FixedColumnWidth(80),
|
|
3: FlexColumnWidth(2),
|
|
4: FixedColumnWidth(40),
|
|
},
|
|
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
|
|
children: [
|
|
TableRow(
|
|
decoration: BoxDecoration(color: isDraft ? Colors.black26 : Colors.grey.shade100),
|
|
children: [
|
|
_TableCell("品名", textColor: textColor),
|
|
_TableCell("数量", textColor: textColor),
|
|
_TableCell("単価", textColor: textColor),
|
|
_TableCell("金額", textColor: textColor),
|
|
const _TableCell(""),
|
|
],
|
|
),
|
|
..._items.asMap().entries.map((entry) {
|
|
int idx = entry.key;
|
|
InvoiceItem item = entry.value;
|
|
if (_isEditing) {
|
|
return TableRow(children: [
|
|
_EditableCell(
|
|
initialValue: item.description,
|
|
textColor: textColor,
|
|
onChanged: (val) {
|
|
setState(() => item.description = val);
|
|
_pushHistory();
|
|
},
|
|
),
|
|
_EditableCell(
|
|
initialValue: item.quantity.toString(),
|
|
textColor: textColor,
|
|
keyboardType: TextInputType.number,
|
|
onChanged: (val) {
|
|
setState(() => item.quantity = int.tryParse(val) ?? 0);
|
|
_pushHistory();
|
|
},
|
|
),
|
|
_EditableCell(
|
|
initialValue: item.unitPrice.toString(),
|
|
textColor: textColor,
|
|
keyboardType: TextInputType.number,
|
|
onChanged: (val) {
|
|
setState(() => item.unitPrice = int.tryParse(val) ?? 0);
|
|
_pushHistory();
|
|
},
|
|
),
|
|
_TableCell(formatter.format(item.subtotal), textColor: textColor),
|
|
IconButton(icon: const Icon(Icons.delete, size: 20, color: Colors.red), onPressed: () => _removeItem(idx)),
|
|
]);
|
|
} else {
|
|
return TableRow(children: [
|
|
_TableCell(item.description, textColor: textColor),
|
|
_TableCell(item.quantity.toString(), textColor: textColor),
|
|
_TableCell(formatter.format(item.unitPrice), textColor: textColor),
|
|
_TableCell(formatter.format(item.subtotal), textColor: textColor),
|
|
const SizedBox(),
|
|
]);
|
|
}
|
|
}),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildSummarySection(NumberFormat formatter, Color textColor, bool isDraft) {
|
|
final double currentTaxRate = _isEditing ? (_includeTax ? _taxRate : 0.0) : _currentInvoice.taxRate;
|
|
final int subtotal = _isEditing ? _calculateCurrentSubtotal() : _currentInvoice.subtotal;
|
|
final int tax = (subtotal * currentTaxRate).floor();
|
|
final int total = subtotal + tax;
|
|
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.indigo,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildSummaryRow("小計", "¥${formatter.format(subtotal)}", Colors.white70),
|
|
if (currentTaxRate > 0) ...[
|
|
const Divider(color: Colors.white24),
|
|
if (_companyInfo?.taxDisplayMode == 'normal')
|
|
_buildSummaryRow("消費税 (${(currentTaxRate * 100).toInt()}%)", "¥${formatter.format(tax)}", Colors.white70),
|
|
if (_companyInfo?.taxDisplayMode == 'text_only')
|
|
_buildSummaryRow("消費税", "(税別)", Colors.white70),
|
|
],
|
|
const Divider(color: Colors.white24),
|
|
_buildSummaryRow(
|
|
currentTaxRate > 0 ? "合計金額 (税込)" : "合計金額",
|
|
"¥${formatter.format(total)}",
|
|
Colors.white,
|
|
isTotal: true,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSummaryRow(String label, String value, Color textColor, {bool isTotal = false}) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: isTotal ? 18 : 16,
|
|
fontWeight: isTotal ? FontWeight.bold : FontWeight.normal,
|
|
color: textColor,
|
|
),
|
|
),
|
|
Text(
|
|
value,
|
|
style: TextStyle(
|
|
fontSize: isTotal ? 22 : 16,
|
|
fontWeight: FontWeight.bold,
|
|
color: isTotal ? Colors.white : textColor,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
int _calculateCurrentSubtotal() {
|
|
return _items.fold(0, (sum, item) => sum + (item.quantity * item.unitPrice));
|
|
}
|
|
|
|
void _pushHistory({bool clearRedo = false}) {
|
|
if (!_isEditing || _isApplyingSnapshot) return;
|
|
if (_undoStack.length >= 30) _undoStack.removeAt(0);
|
|
_undoStack.add(_DetailSnapshot(
|
|
formalName: _formalNameController.text,
|
|
notes: _notesController.text,
|
|
items: _cloneItemsDetail(_items),
|
|
taxRate: _taxRate,
|
|
includeTax: _includeTax,
|
|
isDraft: _currentInvoice.isDraft,
|
|
));
|
|
if (clearRedo) _redoStack.clear();
|
|
setState(() {});
|
|
}
|
|
|
|
void _undo() {
|
|
if (_undoStack.isEmpty) return;
|
|
final snapshot = _undoStack.removeLast();
|
|
_redoStack.add(_DetailSnapshot(
|
|
formalName: _formalNameController.text,
|
|
notes: _notesController.text,
|
|
items: _cloneItemsDetail(_items),
|
|
taxRate: _taxRate,
|
|
includeTax: _includeTax,
|
|
isDraft: _currentInvoice.isDraft,
|
|
));
|
|
_applySnapshot(snapshot);
|
|
}
|
|
|
|
void _redo() {
|
|
if (_redoStack.isEmpty) return;
|
|
final snapshot = _redoStack.removeLast();
|
|
_undoStack.add(_DetailSnapshot(
|
|
formalName: _formalNameController.text,
|
|
notes: _notesController.text,
|
|
items: _cloneItemsDetail(_items),
|
|
taxRate: _taxRate,
|
|
includeTax: _includeTax,
|
|
isDraft: _currentInvoice.isDraft,
|
|
));
|
|
_applySnapshot(snapshot);
|
|
}
|
|
|
|
void _applySnapshot(_DetailSnapshot snapshot) {
|
|
_isApplyingSnapshot = true;
|
|
setState(() {
|
|
_formalNameController.text = snapshot.formalName;
|
|
_notesController.text = snapshot.notes;
|
|
_items = _cloneItemsDetail(snapshot.items);
|
|
_taxRate = snapshot.taxRate;
|
|
_includeTax = snapshot.includeTax;
|
|
});
|
|
_isApplyingSnapshot = false;
|
|
}
|
|
|
|
Widget _buildExperimentalSection(bool isDraft) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: isDraft ? Colors.black45 : Colors.orange.shade50,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: Colors.orange, width: 1),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text("税率設定 (編集用)", style: TextStyle(fontWeight: FontWeight.bold, color: Colors.orange)),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
Text("消費税: ", style: TextStyle(color: isDraft ? Colors.white70 : Colors.black87)),
|
|
ChoiceChip(
|
|
label: const Text("10%"),
|
|
selected: _taxRate == 0.10,
|
|
onSelected: (val) => setState(() => _taxRate = 0.10),
|
|
),
|
|
const SizedBox(width: 8),
|
|
ChoiceChip(
|
|
label: const Text("8%"),
|
|
selected: _taxRate == 0.08,
|
|
onSelected: (val) => setState(() => _taxRate = 0.08),
|
|
),
|
|
const Spacer(),
|
|
Switch(
|
|
value: _includeTax,
|
|
onChanged: (val) => setState(() => _includeTax = val),
|
|
),
|
|
Text(_includeTax ? "税込表示" : "非課税"),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildFooterActions() {
|
|
if (_isEditing) return const SizedBox();
|
|
return Row(
|
|
children: [
|
|
Expanded(
|
|
child: ElevatedButton.icon(
|
|
onPressed: _previewPdf,
|
|
icon: const Icon(Icons.picture_as_pdf),
|
|
label: const Text("PDFプレビュー"),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: ElevatedButton.icon(
|
|
onPressed: _currentFilePath != null ? _openPdf : null,
|
|
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: _currentFilePath != null ? _sharePdf : null,
|
|
icon: const Icon(Icons.share),
|
|
label: const Text("共有"),
|
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.green, foregroundColor: Colors.white),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Future<void> _showPromoteDialog() async {
|
|
bool showWarning = _showFormalWarning;
|
|
final confirm = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => StatefulBuilder(
|
|
builder: (context, setStateDialog) {
|
|
return AlertDialog(
|
|
title: const Text("正式発行"),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text("この下書き伝票を「確定」として正式に発行しますか?"),
|
|
const SizedBox(height: 8),
|
|
if (showWarning)
|
|
Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.red.shade50,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: Colors.redAccent, width: 1),
|
|
),
|
|
child: const Text(
|
|
"確定すると暗号チェーンシステムに組み込まれ、二度と編集できません。内容を最終確認のうえ実行してください。",
|
|
style: TextStyle(color: Colors.redAccent, fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
SwitchListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: const Text("警告文を表示"),
|
|
value: showWarning,
|
|
onChanged: (val) {
|
|
setStateDialog(() => showWarning = val);
|
|
setState(() => _showFormalWarning = val);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("キャンセル")),
|
|
ElevatedButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.orange),
|
|
child: const Text("正式発行する"),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
|
|
if (confirm == true) {
|
|
final promoted = _currentInvoice.copyWith(isDraft: false);
|
|
await _invoiceRepo.updateInvoice(promoted);
|
|
setState(() {
|
|
_currentInvoice = promoted;
|
|
});
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("伝票を正式発行しました")));
|
|
}
|
|
}
|
|
}
|
|
|
|
Widget _buildDraftToggleEdit() {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: _currentInvoice.isDraft ? Colors.black26 : Colors.orange.shade50,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: Colors.orange, width: 2),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.drafts, color: Colors.orange),
|
|
const SizedBox(width: 12),
|
|
const Expanded(child: Text("下書き状態として保持", style: TextStyle(fontWeight: FontWeight.bold))),
|
|
Switch(
|
|
value: _currentInvoice.isDraft,
|
|
onChanged: (val) {
|
|
setState(() {
|
|
_currentInvoice = _currentInvoice.copyWith(isDraft: val);
|
|
});
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _openPdf() async => await OpenFilex.open(_currentFilePath!);
|
|
Future<void> _sharePdf() async {
|
|
if (_currentFilePath != null) {
|
|
await SharePlus.instance.share(ShareParams(files: [XFile(_currentFilePath!)], text: '請求書送付'));
|
|
}
|
|
}
|
|
|
|
Future<void> _previewPdf() async {
|
|
await Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => InvoicePdfPreviewPage(
|
|
invoice: _currentInvoice,
|
|
isUnlocked: widget.isUnlocked,
|
|
isLocked: _currentInvoice.isLocked,
|
|
allowFormalIssue: true,
|
|
onFormalIssue: () async {
|
|
await _showPromoteDialog();
|
|
return !_currentInvoice.isDraft;
|
|
},
|
|
showShare: true,
|
|
showEmail: true,
|
|
showPrint: true,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TableCell extends StatelessWidget {
|
|
final String text;
|
|
final Color? textColor;
|
|
const _TableCell(this.text, {this.textColor});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.all(8.0),
|
|
child: Text(text, textAlign: TextAlign.right, style: TextStyle(fontSize: 12, color: textColor)),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _EditableCell extends StatelessWidget {
|
|
final String initialValue;
|
|
final TextInputType keyboardType;
|
|
final Function(String) onChanged;
|
|
final Color? textColor;
|
|
|
|
const _EditableCell({
|
|
required this.initialValue,
|
|
required this.onChanged,
|
|
this.keyboardType = TextInputType.text,
|
|
this.textColor,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
|
child: TextField(
|
|
controller: TextEditingController(text: initialValue),
|
|
keyboardType: keyboardType,
|
|
style: TextStyle(fontSize: 14, color: textColor),
|
|
onChanged: onChanged,
|
|
decoration: const InputDecoration(isDense: true, contentPadding: EdgeInsets.all(8)),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|