h-1.flutter.0/lib/screens/invoice_input_screen.dart
2026-03-04 14:55:40 +09:00

1167 lines
43 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 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../models/customer_model.dart';
import '../models/invoice_models.dart';
import '../services/pdf_generator.dart';
import '../services/invoice_repository.dart';
import '../services/customer_repository.dart';
import '../widgets/invoice_pdf_preview_page.dart';
import '../services/gps_service.dart';
import 'customer_master_screen.dart';
import 'product_master_screen.dart';
import '../models/product_model.dart';
import '../services/app_settings_repository.dart';
import '../services/edit_log_repository.dart';
class InvoiceInputForm extends StatefulWidget {
final Function(Invoice invoice, String filePath) onInvoiceGenerated;
final Invoice? existingInvoice; // 追加: 編集時の既存伝票
final DocumentType initialDocumentType;
final bool startViewMode;
final bool showNewBadge;
final bool showCopyBadge;
const InvoiceInputForm({
super.key,
required this.onInvoiceGenerated,
this.existingInvoice, // 追加
this.initialDocumentType = DocumentType.invoice,
this.startViewMode = true,
this.showNewBadge = false,
this.showCopyBadge = false,
});
@override
State<InvoiceInputForm> createState() => _InvoiceInputFormState();
}
List<InvoiceItem> _cloneItems(List<InvoiceItem> source, {bool resetIds = false}) {
return source
.map((e) => InvoiceItem(
id: resetIds ? null : e.id,
productId: e.productId,
description: e.description,
quantity: e.quantity,
unitPrice: e.unitPrice,
))
.toList(growable: true);
}
class _InvoiceInputFormState extends State<InvoiceInputForm> {
final _repository = InvoiceRepository();
final InvoiceRepository _invoiceRepo = InvoiceRepository();
Customer? _selectedCustomer;
final List<InvoiceItem> _items = [];
double _taxRate = 0.10;
bool _includeTax = false;
DocumentType _documentType = DocumentType.invoice; // 追加
DateTime _selectedDate = DateTime.now(); // 追加: 伝票日付
bool _isDraft = true; // デフォルトは下書き
final TextEditingController _subjectController = TextEditingController(); // 追加
bool _isSaving = false; // 保存中フラグ
String? _currentId; // 保存対象のIDコピー時に新規になる
bool _isLocked = false;
final List<_InvoiceSnapshot> _undoStack = [];
final List<_InvoiceSnapshot> _redoStack = [];
bool _isApplyingSnapshot = false;
bool get _canUndo => _undoStack.length > 1;
bool get _canRedo => _redoStack.isNotEmpty;
bool _isViewMode = true; // デフォルトでビューワ
bool _summaryIsBlue = false; // デフォルトは白
final AppSettingsRepository _settingsRepo = AppSettingsRepository();
bool _showNewBadge = false;
bool _showCopyBadge = false;
final EditLogRepository _editLogRepo = EditLogRepository();
List<EditLogEntry> _editLogs = [];
final FocusNode _subjectFocusNode = FocusNode();
String _lastLoggedSubject = "";
bool _hasUnsavedChanges = false;
String _documentTypeLabel(DocumentType type) {
switch (type) {
case DocumentType.estimation:
return "見積書";
case DocumentType.delivery:
return "納品書";
case DocumentType.invoice:
return "請求書";
case DocumentType.receipt:
return "領収書";
}
}
Color _documentTypeColor(DocumentType type) {
switch (type) {
case DocumentType.estimation:
return Colors.blue;
case DocumentType.delivery:
return Colors.teal;
case DocumentType.invoice:
return Colors.indigo;
case DocumentType.receipt:
return Colors.green;
}
}
String _customerNameWithHonorific(Customer customer) {
final base = customer.formalName;
final hasHonorific = RegExp(r'(様|御中|殿)$').hasMatch(base);
return hasHonorific ? base : "$base ${customer.title}";
}
String _ensureCurrentId() {
_currentId ??= DateTime.now().millisecondsSinceEpoch.toString();
return _currentId!;
}
Future<bool> _confirmDiscardChanges() async {
if (!_hasUnsavedChanges || _isSaving) {
return true;
}
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Text('保存されていない変更があります'),
content: const Text('編集した内容を破棄してもよろしいですか?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('編集を続ける'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('破棄する'),
),
],
),
);
return result ?? false;
}
Future<void> _handleBackPressed() async {
final allow = await _confirmDiscardChanges();
if (!mounted || !allow) return;
Navigator.of(context).maybePop();
}
Future<DocumentType?> _pickCopyDocumentType() {
return showDialog<DocumentType>(
context: context,
builder: (context) => SimpleDialog(
title: const Text('コピー後の伝票種別を選択'),
children: DocumentType.values.map((type) {
final isCurrent = type == _documentType;
return SimpleDialogOption(
onPressed: () => Navigator.of(context).pop(type),
child: Row(
children: [
Icon(
isCurrent ? Icons.check_circle : Icons.circle_outlined,
color: _documentTypeColor(type),
),
const SizedBox(width: 12),
Text(_documentTypeLabel(type)),
],
),
);
}).toList(),
),
);
}
Future<void> _copyAsNew() async {
if (widget.existingInvoice == null && _currentId == null) return;
final selectedType = await _pickCopyDocumentType();
if (selectedType == null) return;
final clonedItems = _cloneItems(_items, resetIds: true);
setState(() {
_currentId = DateTime.now().millisecondsSinceEpoch.toString();
_isDraft = true;
_isLocked = false;
_selectedDate = DateTime.now();
_documentType = selectedType;
_items
..clear()
..addAll(clonedItems);
_isViewMode = false;
_showCopyBadge = true;
_showNewBadge = false;
_pushHistory(clearRedo: true);
_editLogs.clear();
});
}
@override
void initState() {
super.initState();
_subjectController.addListener(_onSubjectChanged);
_subjectFocusNode.addListener(() {
if (!_subjectFocusNode.hasFocus) {
final current = _subjectController.text;
if (current != _lastLoggedSubject) {
final id = _ensureCurrentId();
final msg = "件名を『$current』に更新しました";
_editLogRepo.addLog(id, msg).then((_) => _loadEditLogs());
_lastLoggedSubject = current;
}
}
});
_subjectController.addListener(_onSubjectChanged);
_loadInitialData();
}
Future<void> _loadInitialData() async {
_repository.cleanupOrphanedPdfs();
final customerRepo = CustomerRepository();
await customerRepo.getAllCustomers();
final savedSummary = await _settingsRepo.getSummaryTheme();
_summaryIsBlue = savedSummary == 'blue';
_isApplyingSnapshot = true;
setState(() {
// 既存伝票がある場合は初期値を上書き
if (widget.existingInvoice != null) {
final inv = widget.existingInvoice!;
_selectedCustomer = inv.customer;
_items.addAll(inv.items);
_taxRate = inv.taxRate;
_includeTax = inv.taxRate > 0;
_documentType = inv.documentType;
_selectedDate = inv.date;
_isDraft = inv.isDraft;
_currentId = inv.id;
_isLocked = inv.isLocked;
if (inv.subject != null) _subjectController.text = inv.subject!;
} else {
_taxRate = 0;
_includeTax = false;
_isDraft = true;
_documentType = widget.initialDocumentType;
_currentId = null;
_isLocked = false;
}
});
_isApplyingSnapshot = false;
_isViewMode = widget.startViewMode; // 指定に従う
_showNewBadge = widget.showNewBadge;
_showCopyBadge = widget.showCopyBadge;
_pushHistory(clearRedo: true, markDirty: false);
if (_hasUnsavedChanges) {
setState(() => _hasUnsavedChanges = false);
}
_lastLoggedSubject = _subjectController.text;
if (_currentId != null) {
_loadEditLogs();
}
}
@override
void dispose() {
_subjectFocusNode.dispose();
super.dispose();
}
Future<void> _loadEditLogs() async {
if (_currentId == null) return;
final logs = await _editLogRepo.getLogs(_currentId!);
if (!mounted) return;
setState(() => _editLogs = logs);
}
void _onSubjectChanged() {
if (_isApplyingSnapshot) return;
_pushHistory();
}
void _addItem() {
Navigator.push<Product>(
context,
MaterialPageRoute(builder: (_) => const ProductMasterScreen(selectionMode: true)),
).then((product) {
if (product == null) return;
setState(() {
_items.add(InvoiceItem(
productId: product.id,
description: product.name,
quantity: 1,
unitPrice: product.defaultUnitPrice,
));
});
_pushHistory();
final id = _ensureCurrentId();
final msg = "商品「${product.name}」を追加しました";
_editLogRepo.addLog(id, msg).then((_) => _loadEditLogs());
});
}
int get _subTotal => _items.fold(0, (sum, item) => sum + (item.unitPrice * item.quantity));
Future<void> _saveInvoice({bool generatePdf = true}) async {
if (_selectedCustomer == null) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("取引先を選択してください")));
return;
}
if (_items.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("明細を1件以上入力してください")));
return;
}
// GPS情報の取得
final gpsService = GpsService();
final pos = await gpsService.getCurrentLocation();
if (pos != null) {
await gpsService.logLocation(); // 履歴テーブルにも保存
}
final invoiceId = _ensureCurrentId();
final invoice = Invoice(
id: invoiceId,
customer: _selectedCustomer!,
date: _selectedDate,
items: _items,
taxRate: _includeTax ? _taxRate : 0.0,
documentType: _documentType,
customerFormalNameSnapshot: _selectedCustomer!.formalName,
subject: _subjectController.text.isNotEmpty ? _subjectController.text : null, // 追加
notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : null,
latitude: pos?.latitude,
longitude: pos?.longitude,
isDraft: _isDraft, // 追加
);
setState(() => _isSaving = true);
try {
// PDF生成有無に関わらず、まずは保存
if (generatePdf) {
final path = await generateInvoicePdf(invoice);
if (path != null) {
final updatedInvoice = invoice.copyWith(filePath: path);
await _repository.saveInvoice(updatedInvoice);
_currentId = updatedInvoice.id;
if (mounted) widget.onInvoiceGenerated(updatedInvoice, path);
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("伝票を保存し、PDFを生成しました")));
} else {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("PDF生成に失敗しました")));
}
} else {
await _repository.saveInvoice(invoice);
_currentId = invoice.id;
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("伝票を保存しましたPDF未生成")));
}
await _editLogRepo.addLog(_currentId!, "伝票を保存しました");
await _loadEditLogs();
if (mounted) {
setState(() {
_isViewMode = true;
_hasUnsavedChanges = false;
});
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存に失敗しました: $e')));
}
} finally {
if (mounted) setState(() => _isSaving = false);
}
}
void _showPreview() {
if (_selectedCustomer == null) return;
final id = _ensureCurrentId();
_editLogRepo.addLog(id, "PDFプレビューを開きました").then((_) => _loadEditLogs());
final invoice = Invoice(
id: id,
customer: _selectedCustomer!,
date: _selectedDate, // 修正
items: _items,
taxRate: _includeTax ? _taxRate : 0.0,
documentType: _documentType,
customerFormalNameSnapshot: _selectedCustomer!.formalName,
notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : "(非課税)",
isDraft: _isDraft,
isLocked: _isLocked,
);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => InvoicePdfPreviewPage(
invoice: invoice,
isUnlocked: true,
isLocked: _isLocked,
allowFormalIssue: invoice.isDraft && !_isLocked,
onFormalIssue: invoice.isDraft
? () async {
final promoted = invoice.copyWith(id: id, isDraft: false, isLocked: true);
await _invoiceRepo.saveInvoice(promoted);
final newPath = await generateInvoicePdf(promoted);
final saved = newPath != null ? promoted.copyWith(filePath: newPath) : promoted;
await _invoiceRepo.saveInvoice(saved);
await _editLogRepo.addLog(_ensureCurrentId(), "正式発行しました");
if (!context.mounted) return false;
setState(() {
_isDraft = false;
_isLocked = true;
});
return true;
}
: null,
showShare: true,
showEmail: true,
showPrint: true,
),
),
);
}
void _pushHistory({bool clearRedo = false, bool markDirty = true}) {
setState(() {
if (_undoStack.length >= 30) _undoStack.removeAt(0);
_undoStack.add(_InvoiceSnapshot(
customer: _selectedCustomer,
items: _cloneItems(_items),
taxRate: _taxRate,
includeTax: _includeTax,
documentType: _documentType,
date: _selectedDate,
isDraft: _isDraft,
subject: _subjectController.text,
));
if (clearRedo) _redoStack.clear();
if (markDirty) {
_hasUnsavedChanges = true;
}
});
}
void _undo() {
if (_undoStack.length <= 1) return; // 直前状態がない
setState(() {
// 現在の状態をredoへ積む
_redoStack.add(_InvoiceSnapshot(
customer: _selectedCustomer,
items: _cloneItems(_items),
taxRate: _taxRate,
includeTax: _includeTax,
documentType: _documentType,
date: _selectedDate,
isDraft: _isDraft,
subject: _subjectController.text,
));
// 一番新しい履歴を捨て、直前のスナップショットを適用
_undoStack.removeLast();
final snapshot = _undoStack.last;
_isApplyingSnapshot = true;
_selectedCustomer = snapshot.customer;
_items
..clear()
..addAll(_cloneItems(snapshot.items));
_taxRate = snapshot.taxRate;
_includeTax = snapshot.includeTax;
_documentType = snapshot.documentType;
_selectedDate = snapshot.date;
_isDraft = snapshot.isDraft;
_subjectController.text = snapshot.subject;
_isApplyingSnapshot = false;
});
}
void _redo() {
if (_redoStack.isEmpty) return;
setState(() {
_undoStack.add(_InvoiceSnapshot(
customer: _selectedCustomer,
items: _cloneItems(_items),
taxRate: _taxRate,
includeTax: _includeTax,
documentType: _documentType,
date: _selectedDate,
isDraft: _isDraft,
subject: _subjectController.text,
));
final snapshot = _redoStack.removeLast();
_isApplyingSnapshot = true;
_selectedCustomer = snapshot.customer;
_items
..clear()
..addAll(_cloneItems(snapshot.items));
_taxRate = snapshot.taxRate;
_includeTax = snapshot.includeTax;
_documentType = snapshot.documentType;
_selectedDate = snapshot.date;
_isDraft = snapshot.isDraft;
_subjectController.text = snapshot.subject;
_isApplyingSnapshot = false;
});
}
@override
Widget build(BuildContext context) {
final fmt = NumberFormat("#,###");
final themeColor = Theme.of(context).scaffoldBackgroundColor;
final textColor = Colors.black87;
final docColor = _documentTypeColor(_documentType);
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
final allow = await _confirmDiscardChanges();
if (allow && context.mounted) {
Navigator.of(context).pop(result);
}
},
child: Scaffold(
backgroundColor: themeColor,
resizeToAvoidBottomInset: false,
appBar: AppBar(
backgroundColor: docColor,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => _handleBackPressed(),
),
title: Text("A1:${_documentTypeLabel(_documentType)}"),
actions: [
if (_isDraft)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: _DraftBadge(),
),
IconButton(
icon: const Icon(Icons.copy),
tooltip: "コピーして新規",
onPressed: () => _copyAsNew(),
),
if (_isLocked)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Icon(Icons.lock, color: Colors.white),
)
else if (_isViewMode)
IconButton(
icon: const Icon(Icons.edit),
tooltip: "編集モードにする",
onPressed: () => setState(() => _isViewMode = false),
)
else ...[
IconButton(
icon: const Icon(Icons.undo),
onPressed: _canUndo ? _undo : null,
tooltip: "元に戻す",
),
IconButton(
icon: const Icon(Icons.redo),
onPressed: _canRedo ? _redo : null,
tooltip: "やり直す",
),
if (!_isLocked)
IconButton(
icon: const Icon(Icons.save),
tooltip: "保存",
onPressed: _isSaving ? null : () => _saveInvoice(generatePdf: false),
),
],
],
),
body: Stack(
children: [
Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(16, 16, 16, MediaQuery.of(context).viewInsets.bottom + 140),
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDateSection(),
const SizedBox(height: 16),
_buildCustomerSection(),
const SizedBox(height: 16),
_buildSubjectSection(textColor),
const SizedBox(height: 20),
_buildItemsSection(fmt),
const SizedBox(height: 20),
_buildSummarySection(fmt),
const SizedBox(height: 12),
_buildEditLogsSection(),
const SizedBox(height: 20),
],
),
),
),
_buildBottomActionBar(),
],
),
if (_isSaving)
Container(
color: Colors.black54,
child: const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(color: Colors.white),
SizedBox(height: 16),
Text("保存中...", style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
],
),
),
),
],
),
),
);
}
Widget _buildDateSection() {
final fmt = DateFormat('yyyy/MM/dd');
return GestureDetector(
onTap: _isViewMode
? null
: () async {
final picked = await showDatePicker(
context: context,
initialDate: _selectedDate,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null) {
setState(() => _selectedDate = picked);
_pushHistory();
}
},
child: Align(
alignment: Alignment.centerLeft,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 8, offset: const Offset(0, 3))],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.calendar_today, size: 18, color: Colors.indigo),
const SizedBox(width: 8),
Text("伝票日付: ${fmt.format(_selectedDate)}", style: const TextStyle(fontWeight: FontWeight.bold)),
if (_showNewBadge)
Container(
margin: const EdgeInsets.only(left: 8),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.orange.shade100,
borderRadius: BorderRadius.circular(10),
),
child: const Text("新規", style: TextStyle(color: Colors.deepOrange, fontSize: 11, fontWeight: FontWeight.bold)),
),
if (_showCopyBadge)
Container(
margin: const EdgeInsets.only(left: 8),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.blue.shade100,
borderRadius: BorderRadius.circular(10),
),
child: const Text("複写", style: TextStyle(color: Colors.blue, fontSize: 11, fontWeight: FontWeight.bold)),
),
if (!_isViewMode && !_isLocked) ...[
const SizedBox(width: 8),
const Icon(Icons.chevron_right, size: 18, color: Colors.indigo),
],
],
),
),
),
);
}
Widget _buildCustomerSection() {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 8, offset: const Offset(0, 3))],
),
child: ListTile(
leading: const Icon(Icons.business, color: Colors.blueGrey),
title: Text(
_selectedCustomer != null ? _customerNameWithHonorific(_selectedCustomer!) : "取引先を選択してください",
style: TextStyle(color: _selectedCustomer == null ? Colors.grey : Colors.black87, fontWeight: FontWeight.bold)),
subtitle: _isViewMode ? null : const Text("顧客マスターから選択"),
trailing: (_isViewMode || _isLocked) ? null : const Icon(Icons.chevron_right),
onTap: (_isViewMode || _isLocked)
? null
: () async {
final Customer? picked = await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => CustomerMasterScreen(selectionMode: true),
fullscreenDialog: true,
),
);
if (picked != null) {
setState(() => _selectedCustomer = picked);
_pushHistory();
}
},
),
);
}
Widget _buildItemsSection(NumberFormat fmt) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text("明細項目", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
if (!_isViewMode && !_isLocked)
TextButton.icon(onPressed: _addItem, icon: const Icon(Icons.add), label: const Text("追加")),
],
),
if (_items.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 20),
child: Center(child: Text("商品が追加されていません", style: TextStyle(color: Colors.grey))),
)
else if (_isViewMode)
Column(
children: _items
.map((item) => Card(
margin: const EdgeInsets.only(bottom: 6),
elevation: 0.5,
child: ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
title: Text(item.description, style: const TextStyle(fontSize: 13.5)),
subtitle: Text("${fmt.format(item.unitPrice)} x ${item.quantity}", style: const TextStyle(fontSize: 12.5)),
trailing: Text(
"${fmt.format(item.unitPrice * item.quantity)}",
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13.5),
),
),
))
.toList(),
)
else
ReorderableListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _items.length,
onReorder: (oldIndex, newIndex) async {
int targetIndex = newIndex;
setState(() {
if (targetIndex > oldIndex) targetIndex -= 1;
final item = _items.removeAt(oldIndex);
_items.insert(targetIndex, item);
});
_pushHistory();
final id = _ensureCurrentId();
final item = _items[targetIndex];
final msg = "明細を並べ替えました: ${item.description}${oldIndex + 1}${targetIndex + 1}";
await _editLogRepo.addLog(id, msg);
await _loadEditLogs();
},
buildDefaultDragHandles: false,
itemBuilder: (context, idx) {
final item = _items[idx];
return ReorderableDelayedDragStartListener(
key: ValueKey('item_${idx}_${item.description}'),
index: idx,
child: Card(
margin: const EdgeInsets.only(bottom: 6),
elevation: 0.5,
child: ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
title: Text(item.description, style: const TextStyle(fontSize: 13.5)),
subtitle: Text("${fmt.format(item.unitPrice)} x ${item.quantity}", style: const TextStyle(fontSize: 12.5)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!_isViewMode && !_isLocked) ...[
IconButton(
icon: const Icon(Icons.remove, size: 18),
onPressed: () async {
if (item.quantity <= 1) return;
setState(() => _items[idx] = item.copyWith(quantity: item.quantity - 1));
_pushHistory();
final id = _ensureCurrentId();
final msg = "${item.description} の数量を ${item.quantity - 1} に変更しました";
await _editLogRepo.addLog(id, msg);
await _loadEditLogs();
},
constraints: const BoxConstraints.tightFor(width: 28, height: 28),
padding: EdgeInsets.zero,
),
Text('${item.quantity}', style: const TextStyle(fontSize: 12.5)),
IconButton(
icon: const Icon(Icons.add, size: 18),
onPressed: () async {
setState(() => _items[idx] = item.copyWith(quantity: item.quantity + 1));
_pushHistory();
final id = _ensureCurrentId();
final msg = "${item.description} の数量を ${item.quantity + 1} に変更しました";
await _editLogRepo.addLog(id, msg);
await _loadEditLogs();
},
constraints: const BoxConstraints.tightFor(width: 28, height: 28),
padding: EdgeInsets.zero,
),
],
Text("${fmt.format(item.unitPrice * item.quantity)}", style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13.5)),
const SizedBox(width: 6),
IconButton(
icon: const Icon(Icons.remove_circle_outline, color: Colors.redAccent, size: 18),
onPressed: () async {
final removed = _items[idx];
setState(() => _items.removeAt(idx));
_pushHistory();
final id = _ensureCurrentId();
final msg = "商品「${removed.description}」を削除しました";
await _editLogRepo.addLog(id, msg);
await _loadEditLogs();
},
tooltip: "削除",
constraints: const BoxConstraints.tightFor(width: 32, height: 32),
padding: EdgeInsets.zero,
),
],
),
onTap: () async {
if (_isViewMode || _isLocked) return;
final messenger = ScaffoldMessenger.of(context);
final product = await Navigator.push<Product>(
context,
MaterialPageRoute(builder: (_) => const ProductMasterScreen(selectionMode: true)),
);
if (product != null) {
if (!mounted) return;
final prevDesc = item.description;
setState(() {
_items[idx] = item.copyWith(
productId: product.id,
description: product.name,
unitPrice: product.defaultUnitPrice,
);
});
_pushHistory();
final id = _ensureCurrentId();
final msg = "商品を $prevDesc から ${product.name} に変更しました";
await _editLogRepo.addLog(id, msg);
await _loadEditLogs();
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text(msg)));
}
},
),
),
);
},
),
],
);
}
Widget _buildSummarySection(NumberFormat formatter) {
final int subtotal = _subTotal;
final int tax = _includeTax ? (subtotal * _taxRate).floor() : 0;
final int total = subtotal + tax;
final useBlue = _summaryIsBlue;
final bgColor = useBlue ? Colors.indigo : Colors.white;
final borderColor = Colors.transparent;
final labelColor = useBlue ? Colors.white70 : Colors.black87;
final totalColor = useBlue ? Colors.white : Colors.black87;
final dividerColor = useBlue ? Colors.white24 : Colors.grey.shade300;
return GestureDetector(
onLongPress: () async {
final selected = await showModalBottomSheet<String>(
context: context,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.palette, color: Colors.indigo),
title: const Text('インディゴ'),
onTap: () => Navigator.pop(context, 'blue'),
),
ListTile(
leading: const Icon(Icons.palette, color: Colors.grey),
title: const Text(''),
onTap: () => Navigator.pop(context, 'white'),
),
],
),
),
);
if (selected == null) return;
setState(() => _summaryIsBlue = selected == 'blue');
await _settingsRepo.setSummaryTheme(selected);
},
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: borderColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSummaryRow("小計", "${formatter.format(subtotal)}", labelColor),
if (tax > 0) ...[
Divider(color: dividerColor),
_buildSummaryRow("消費税", "${formatter.format(tax)}", labelColor),
],
Divider(color: dividerColor),
_buildSummaryRow(
tax > 0 ? "合計金額 (税込)" : "合計金額",
"${formatter.format(total)}",
totalColor,
isTotal: true,
),
],
),
),
);
}
Widget _buildSummaryRow(String label, String value, Color textColor, {bool isTotal = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: TextStyle(
fontSize: isTotal ? 16 : 14,
fontWeight: isTotal ? FontWeight.w600 : FontWeight.normal,
color: textColor,
),
),
Text(
value,
style: TextStyle(
fontSize: isTotal ? 18 : 14,
fontWeight: FontWeight.w600,
color: isTotal ? Colors.white : textColor,
),
),
],
),
);
}
Widget _buildBottomActionBar() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10, offset: const Offset(0, -5))],
),
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _items.isEmpty ? null : _showPreview,
icon: const Icon(Icons.picture_as_pdf), // アイコン変更
label: const Text("PDFプレビュー"), // 名称変更
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
side: const BorderSide(color: Colors.indigo),
),
),
),
const SizedBox(width: 8),
Expanded(
child: _isLocked
? ElevatedButton.icon(
onPressed: null,
icon: const Icon(Icons.lock),
label: const Text("ロック中"),
)
: (_isViewMode
? ElevatedButton.icon(
onPressed: () => setState(() => _isViewMode = false),
icon: const Icon(Icons.edit),
label: const Text("編集"),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
),
)
: ElevatedButton.icon(
onPressed: () => _saveInvoice(generatePdf: false),
icon: const Icon(Icons.save),
label: const Text("保存"),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
),
)),
),
],
),
],
),
),
);
}
Widget _buildSubjectSection(Color textColor) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("案件名 / 件名", style: TextStyle(fontWeight: FontWeight.bold, color: textColor)),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 8, offset: const Offset(0, 3))],
),
child: TextField(
focusNode: _subjectFocusNode,
controller: _subjectController,
style: TextStyle(color: textColor),
readOnly: _isViewMode || _isLocked,
enableInteractiveSelection: !(_isViewMode || _isLocked),
decoration: InputDecoration(
hintText: "例:事務所改修工事 / 〇〇月分リース料",
hintStyle: TextStyle(color: textColor.withAlpha((0.5 * 255).round())),
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
),
),
),
],
);
}
Widget _buildEditLogsSection() {
if (_currentId == null) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 0.5,
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: const [
BoxShadow(
color: Color(0x22000000),
blurRadius: 10,
spreadRadius: -4,
offset: Offset(0, 2),
blurStyle: BlurStyle.inner,
),
],
),
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text("編集ログ (直近1週間)", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
const SizedBox(height: 8),
if (_editLogs.isEmpty)
const Text("編集ログはありません", style: TextStyle(color: Colors.grey, fontSize: 12))
else
..._editLogs.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.circle, size: 6, color: Colors.grey),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
DateFormat('yyyy/MM/dd HH:mm').format(e.createdAt),
style: const TextStyle(fontSize: 11, color: Colors.black54),
),
Text(
e.message,
style: const TextStyle(fontSize: 13),
),
],
),
),
],
),
)),
],
),
),
),
],
);
}
}
class _DraftBadge extends StatelessWidget {
const _DraftBadge();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.orange.shade100,
borderRadius: BorderRadius.circular(10),
),
child: const Text(
'下書き',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Colors.orange),
),
);
}
}
class _InvoiceSnapshot {
final Customer? customer;
final List<InvoiceItem> items;
final double taxRate;
final bool includeTax;
final DocumentType documentType;
final DateTime date;
final bool isDraft;
final String subject;
_InvoiceSnapshot({
required this.customer,
required this.items,
required this.taxRate,
required this.includeTax,
required this.documentType,
required this.date,
required this.isDraft,
required this.subject,
});
}