h-1.flutter.0/lib/screens/sales_entries_screen.dart

1671 lines
58 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:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:uuid/uuid.dart';
import '../models/customer_model.dart';
import '../models/invoice_models.dart';
import '../models/sales_entry_models.dart';
import '../services/app_settings_repository.dart';
import '../services/edit_log_repository.dart';
import '../services/sales_entry_service.dart';
import '../widgets/line_item_editor.dart';
import '../widgets/modal_utils.dart';
import '../widgets/screen_id_title.dart';
import 'customer_picker_modal.dart';
import 'settings_screen.dart';
import 'product_picker_modal.dart';
class SalesEntriesScreen extends StatefulWidget {
const SalesEntriesScreen({super.key});
@override
State<SalesEntriesScreen> createState() => _SalesEntriesScreenState();
}
class _SalesEntriesScreenState extends State<SalesEntriesScreen> {
final SalesEntryService _service = SalesEntryService();
final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥');
bool _isLoading = true;
bool _isRefreshing = false;
List<SalesEntry> _entries = const [];
SalesEntryStatus? _filterStatus;
@override
void initState() {
super.initState();
_loadEntries();
}
Future<void> _loadEntries() async {
if (!_isRefreshing) {
setState(() => _isLoading = true);
}
try {
final entries = await _service.fetchEntries(status: _filterStatus);
if (!mounted) return;
setState(() {
_entries = entries;
_isLoading = false;
_isRefreshing = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_isLoading = false;
_isRefreshing = false;
});
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('売上伝票の取得に失敗しました: $e')));
}
}
Future<void> _handleRefresh() async {
setState(() => _isRefreshing = true);
await _loadEntries();
}
Future<void> _openEditor({SalesEntry? entry}) async {
final updated = await Navigator.of(context).push<SalesEntry>(
MaterialPageRoute(builder: (_) => _SalesEntryEditorPage(service: _service, entry: entry)),
);
if (updated != null) {
await _loadEntries();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('売上伝票を保存しました')));
}
}
Future<void> _openImportSheet() async {
final imported = await SalesEntryImportSheet.show(context, _service);
if (imported != null) {
await _loadEntries();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('伝票をインポートしました: ${imported.subject ?? '売上伝票'}')));
}
}
Future<void> _handleReimport(SalesEntry entry) async {
try {
final updated = await _service.reimportEntry(entry.id);
if (!mounted) return;
setState(() {
final index = _entries.indexWhere((e) => e.id == entry.id);
if (index != -1) {
_entries = List.of(_entries)..[index] = updated;
}
});
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('再インポートが完了しました')));
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('再インポートに失敗しました: $e')));
}
}
Future<void> _confirmDelete(SalesEntry entry) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('伝票を削除'),
content: Text('${entry.subject ?? '無題'}を削除しますか?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('キャンセル')),
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('削除')),
],
),
);
if (confirmed != true) return;
try {
await _service.deleteEntry(entry.id);
if (!mounted) return;
setState(() {
_entries = _entries.where((e) => e.id != entry.id).toList();
});
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('伝票を削除しました')));
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('削除に失敗しました: $e')));
}
}
@override
Widget build(BuildContext context) {
final body = _isLoading
? const Center(child: CircularProgressIndicator())
: RefreshIndicator(
onRefresh: _handleRefresh,
child: _entries.isEmpty
? ListView(
children: const [
SizedBox(height: 120),
Icon(Icons.description_outlined, size: 64, color: Colors.grey),
SizedBox(height: 12),
Center(child: Text('売上伝票がありません。インポートまたは新規作成してください。')),
],
)
: ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 120),
itemCount: _entries.length,
separatorBuilder: (_, index) => const SizedBox(height: 12),
itemBuilder: (context, index) => _buildEntryCard(_entries[index]),
),
);
return Scaffold(
backgroundColor: Colors.grey.shade200,
appBar: AppBar(
leading: const BackButton(),
title: const ScreenAppBarTitle(screenId: 'U1', title: '売上伝票'),
actions: [
IconButton(onPressed: _openImportSheet, tooltip: 'インポート', icon: const Icon(Icons.download)),
IconButton(onPressed: () => _openEditor(), tooltip: '新規作成', icon: const Icon(Icons.add)),
PopupMenuButton<SalesEntryStatus?>(
tooltip: 'ステータス絞り込み',
icon: const Icon(Icons.filter_alt),
onSelected: (value) {
setState(() => _filterStatus = value);
_loadEntries();
},
itemBuilder: (context) => [
const PopupMenuItem(value: null, child: Text('すべて表示')),
...SalesEntryStatus.values.map(
(status) => PopupMenuItem(value: status, child: Text(status.displayName)),
),
],
),
],
),
body: body,
floatingActionButton: FloatingActionButton.extended(
onPressed: _openImportSheet,
icon: const Icon(Icons.receipt_long),
label: const Text('伝票インポート'),
),
);
}
Widget _buildEntryCard(SalesEntry entry) {
final amountLabel = _currencyFormat.format(entry.amountTaxIncl);
final dateLabel = DateFormat('yyyy/MM/dd').format(entry.issueDate);
final subject = entry.subject?.trim().isNotEmpty == true ? entry.subject!.trim() : '売上伝票';
final customer = entry.customerNameSnapshot ?? '取引先未設定';
Color statusColor(SalesEntryStatus status) {
switch (status) {
case SalesEntryStatus.draft:
return Colors.orange;
case SalesEntryStatus.confirmed:
return Colors.blue;
case SalesEntryStatus.settled:
return Colors.green;
}
}
final baseColor = statusColor(entry.status);
final statusChip = Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: baseColor.withAlpha((0.15 * 255).round()),
borderRadius: BorderRadius.circular(999),
),
child: Text(entry.status.displayName, style: TextStyle(color: baseColor, fontSize: 12)),
);
return Card(
color: Colors.white,
child: InkWell(
onTap: () => _openEditor(entry: entry),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
subject,
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
statusChip,
PopupMenuButton<String>(
onSelected: (value) {
switch (value) {
case 'edit':
_openEditor(entry: entry);
break;
case 'reimport':
_handleReimport(entry);
break;
case 'delete':
_confirmDelete(entry);
break;
}
},
itemBuilder: (context) => const [
PopupMenuItem(value: 'edit', child: Text('編集')),
PopupMenuItem(value: 'reimport', child: Text('再インポート')),
PopupMenuItem(value: 'delete', child: Text('削除')),
],
),
],
),
const SizedBox(height: 8),
Text(customer, style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 4),
Row(
children: [
Text('計上日: $dateLabel'),
const Spacer(),
Text(amountLabel, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
],
),
if (entry.notes?.isNotEmpty == true) ...[
const SizedBox(height: 8),
Text(entry.notes!, style: Theme.of(context).textTheme.bodySmall),
],
],
),
),
),
);
}
}
class _EntrySnapshot {
const _EntrySnapshot({
required this.customer,
required this.customerSnapshot,
required this.subject,
required this.notes,
required this.issueDate,
required this.status,
required this.cashSaleMode,
required this.settlementMethod,
required this.settlementCardCompany,
required this.settlementDueDate,
required this.lines,
});
final Customer? customer;
final String? customerSnapshot;
final String subject;
final String notes;
final DateTime issueDate;
final SalesEntryStatus status;
final bool cashSaleMode;
final SettlementMethod? settlementMethod;
final String settlementCardCompany;
final DateTime? settlementDueDate;
final List<_LineDraft> lines;
bool isSame(_EntrySnapshot other) {
return customer == other.customer &&
customerSnapshot == other.customerSnapshot &&
subject == other.subject &&
notes == other.notes &&
issueDate == other.issueDate &&
status == other.status &&
cashSaleMode == other.cashSaleMode &&
settlementMethod == other.settlementMethod &&
settlementCardCompany == other.settlementCardCompany &&
settlementDueDate == other.settlementDueDate &&
listEquals(lines, other.lines);
}
}
class _LineDraft {
const _LineDraft({
this.id,
this.productId,
required this.description,
required this.quantity,
required this.unitPrice,
this.taxRate,
required this.costAmount,
required this.costIsProvisional,
});
final String? id;
final String? productId;
final String description;
final int quantity;
final int unitPrice;
final double? taxRate;
final int costAmount;
final bool costIsProvisional;
factory _LineDraft.fromForm(LineItemFormData form) {
return _LineDraft(
id: form.id,
productId: form.productId,
description: form.description,
quantity: form.quantityValue,
unitPrice: form.unitPriceValue,
taxRate: form.taxRate,
costAmount: form.costAmount,
costIsProvisional: form.costIsProvisional,
);
}
LineItemFormData toFormData() {
return LineItemFormData(
id: id,
productId: productId,
productName: description,
quantity: quantity,
unitPrice: unitPrice,
taxRate: taxRate,
costAmount: costAmount,
costIsProvisional: costIsProvisional,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is _LineDraft &&
other.id == id &&
other.productId == productId &&
other.description == description &&
other.quantity == quantity &&
other.unitPrice == unitPrice &&
other.taxRate == taxRate &&
other.costAmount == costAmount &&
other.costIsProvisional == costIsProvisional;
}
@override
int get hashCode => Object.hash(id, productId, description, quantity, unitPrice, taxRate, costAmount, costIsProvisional);
}
class _SalesEntryEditorPage extends StatefulWidget {
const _SalesEntryEditorPage({required this.service, this.entry});
final SalesEntryService service;
final SalesEntry? entry;
@override
State<_SalesEntryEditorPage> createState() => _SalesEntryEditorPageState();
}
class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> {
final _subjectController = TextEditingController();
final _notesController = TextEditingController();
final Uuid _uuid = const Uuid();
final DateFormat _dateFormat = DateFormat('yyyy/MM/dd');
final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥');
final EditLogRepository _editLogRepo = EditLogRepository();
final AppSettingsRepository _settingsRepo = AppSettingsRepository();
late DateTime _issueDate;
Customer? _selectedCustomer;
String? _customerSnapshot;
SettlementMethod? _settlementMethod;
final TextEditingController _cardCompanyController = TextEditingController();
DateTime? _settlementDueDate;
SalesEntryStatus _status = SalesEntryStatus.draft;
bool _isSaving = false;
final List<LineItemFormData> _lines = [];
List<EditLogEntry> _editLogs = const [];
String? _entryId;
bool _isLoadingLogs = false;
bool _grossEnabled = true;
bool _grossToggleVisible = true;
bool _grossIncludeProvisional = false;
bool _showGross = true;
bool _cashSaleMode = false;
final String _cashSaleLabel = '現金売上';
bool _cashSaleModeUserOverride = false;
bool _showGrossUserOverride = false;
bool _isQuickSettingsDrawerOpen = false;
static final RegExp _honorificPattern = RegExp(r'(様|さま|御中|殿|貴社|先生|氏)$');
final List<_EntrySnapshot> _undoStack = [];
final List<_EntrySnapshot> _redoStack = [];
bool _isApplyingSnapshot = false;
Timer? _historyDebounce;
@override
void initState() {
super.initState();
final entry = widget.entry;
_cashSaleModeUserOverride = entry != null;
_issueDate = entry?.issueDate ?? DateTime.now();
_status = entry?.status ?? SalesEntryStatus.draft;
_customerSnapshot = _withHonorific(entry?.customerNameSnapshot);
_settlementMethod = entry?.settlementMethod;
_cardCompanyController.text = entry?.settlementCardCompany ?? '';
_settlementDueDate = entry?.settlementDueDate;
_subjectController.text = entry?.subject ?? '';
_notesController.text = entry?.notes ?? '';
_entryId = entry?.id;
_cashSaleMode = entry == null
? false
: (entry.customerId == null && entry.customerNameSnapshot == _cashSaleLabel);
if (entry != null) {
for (final item in entry.items) {
final form = LineItemFormData(
id: item.id,
productId: item.productId,
productName: item.description,
quantity: item.quantity,
unitPrice: item.unitPrice,
taxRate: item.taxRate,
costAmount: item.costAmount,
costIsProvisional: item.costIsProvisional,
);
_attachLineListeners(form);
_lines.add(form);
}
}
if (_lines.isEmpty) {
final form = LineItemFormData();
_attachLineListeners(form);
_lines.add(form);
}
if (_entryId != null) {
_loadEditLogs();
}
_loadEditorPreferences();
_subjectController.addListener(_scheduleHistorySnapshot);
_notesController.addListener(_scheduleHistorySnapshot);
_cardCompanyController.addListener(_scheduleHistorySnapshot);
WidgetsBinding.instance.addPostFrameCallback((_) => _initializeHistory());
}
@override
void dispose() {
_historyDebounce?.cancel();
_subjectController.removeListener(_scheduleHistorySnapshot);
_notesController.removeListener(_scheduleHistorySnapshot);
_cardCompanyController.removeListener(_scheduleHistorySnapshot);
_subjectController.dispose();
_notesController.dispose();
_cardCompanyController.dispose();
for (final line in _lines) {
line.removeChangeListener(_scheduleHistorySnapshot);
line.dispose();
}
super.dispose();
}
Widget _buildBackButton() {
return Tooltip(
message: '戻る / 長押しで表示モード設定',
child: SizedBox(
width: kToolbarHeight,
height: kToolbarHeight,
child: InkResponse(
radius: 28,
containedInkWell: true,
highlightShape: BoxShape.circle,
onTap: () => Navigator.of(context).maybePop(),
onLongPress: _openQuickSettingsDrawer,
child: const Center(child: Icon(Icons.arrow_back)),
),
),
);
}
String? _withHonorific(String? value, {String? fallbackHonorific}) {
if (value == null) return null;
final trimmed = value.trimRight();
if (trimmed.isEmpty) return value;
if (_honorificPattern.hasMatch(trimmed)) {
return trimmed;
}
final candidate = (fallbackHonorific ?? _selectedCustomer?.title ?? '').trim();
if (candidate.isEmpty) {
return trimmed;
}
return '$trimmed $candidate';
}
String _ensureEntryId() {
return _entryId ??= widget.entry?.id ?? _uuid.v4();
}
void _logEdit(String message) {
final id = _ensureEntryId();
_editLogRepo.addLog(id, message).then((_) => _loadEditLogs());
}
void _initializeHistory() {
_undoStack
..clear()
..add(_captureSnapshot());
_redoStack.clear();
}
void _attachLineListeners(LineItemFormData line) {
line.registerChangeListener(_scheduleHistorySnapshot);
}
void _scheduleHistorySnapshot() {
if (_isApplyingSnapshot) return;
_historyDebounce?.cancel();
_historyDebounce = Timer(const Duration(milliseconds: 500), () {
_pushHistory(clearRedo: true);
});
}
void _pushHistory({bool clearRedo = false}) {
if (_isApplyingSnapshot) return;
final snapshot = _captureSnapshot();
if (_undoStack.isNotEmpty && _undoStack.last.isSame(snapshot)) {
return;
}
setState(() {
if (_undoStack.length >= 50) {
_undoStack.removeAt(0);
}
_undoStack.add(snapshot);
if (clearRedo) {
_redoStack.clear();
}
});
}
_EntrySnapshot _captureSnapshot() {
return _EntrySnapshot(
customer: _selectedCustomer,
customerSnapshot: _customerSnapshot,
subject: _subjectController.text,
notes: _notesController.text,
issueDate: _issueDate,
status: _status,
cashSaleMode: _cashSaleMode,
settlementMethod: _settlementMethod,
settlementCardCompany: _cardCompanyController.text,
settlementDueDate: _settlementDueDate,
lines: _lines.map(_LineDraft.fromForm).toList(growable: false),
);
}
void _applySnapshot(_EntrySnapshot snapshot) {
_isApplyingSnapshot = true;
_historyDebounce?.cancel();
for (final line in _lines) {
line.removeChangeListener(_scheduleHistorySnapshot);
line.dispose();
}
_lines
..clear()
..addAll(snapshot.lines.map((draft) {
final form = draft.toFormData();
_attachLineListeners(form);
return form;
}));
_selectedCustomer = snapshot.customer;
_customerSnapshot = _withHonorific(snapshot.customerSnapshot, fallbackHonorific: snapshot.customer?.title);
_subjectController.text = snapshot.subject;
_notesController.text = snapshot.notes;
_issueDate = snapshot.issueDate;
_status = snapshot.status;
_cashSaleMode = snapshot.cashSaleMode;
_settlementMethod = snapshot.settlementMethod;
_cardCompanyController.text = snapshot.settlementCardCompany;
_settlementDueDate = snapshot.settlementDueDate;
_isApplyingSnapshot = false;
setState(() {});
}
bool get _canUndo => _undoStack.length > 1;
bool get _canRedo => _redoStack.isNotEmpty;
void _undo() {
if (!_canUndo) return;
final current = _captureSnapshot();
setState(() {
_redoStack.add(current);
_undoStack.removeLast();
final snapshot = _undoStack.last;
_applySnapshot(snapshot);
});
}
void _redo() {
if (!_canRedo) return;
final snapshot = _redoStack.removeLast();
setState(() {
_undoStack.add(snapshot);
_applySnapshot(snapshot);
});
}
Future<void> _loadEditorPreferences() async {
final enabled = await _settingsRepo.getGrossProfitEnabled();
final toggleVisible = await _settingsRepo.getGrossProfitToggleVisible();
final includeProvisional = await _settingsRepo.getGrossProfitIncludeProvisional();
final defaultCash = await _settingsRepo.getSalesEntryCashModeDefault();
final showGross = await _settingsRepo.getSalesEntryShowGross();
if (!mounted) return;
setState(() {
_grossEnabled = enabled;
_grossToggleVisible = toggleVisible;
_grossIncludeProvisional = includeProvisional;
if (!_cashSaleModeUserOverride && widget.entry == null) {
_toggleCashSaleMode(defaultCash, userAction: false);
}
if (!_showGrossUserOverride) {
_showGross = enabled && showGross;
} else if (!enabled) {
_showGross = false;
}
});
}
Future<void> _loadEditLogs() async {
final id = _entryId;
if (id == null) return;
setState(() => _isLoadingLogs = true);
final logs = await _editLogRepo.getLogs(id);
if (!mounted) return;
setState(() {
_editLogs = logs;
_isLoadingLogs = false;
});
}
Widget _buildEditLogPanel() {
final hasEntryId = _entryId != null;
return Card(
color: Colors.grey.shade100,
margin: const EdgeInsets.only(top: 24),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Expanded(
child: Text(
'編集ログ',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
IconButton(
tooltip: '再読込',
icon: const Icon(Icons.refresh),
onPressed: hasEntryId ? _loadEditLogs : null,
),
],
),
const SizedBox(height: 8),
if (!hasEntryId)
const Text(
'保存すると編集ログが表示されます。',
style: TextStyle(color: Colors.grey),
)
else if (_isLoadingLogs)
const SizedBox(
height: 48,
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
)
else if (_editLogs.isEmpty)
const Text(
'編集ログはまだありません。',
style: TextStyle(color: Colors.grey),
)
else ...[
..._editLogs.take(10).map((log) {
final timestamp = DateFormat('yyyy/MM/dd HH:mm').format(log.createdAt);
return 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(
timestamp,
style: const TextStyle(fontSize: 11, color: Colors.black54),
),
Text(log.message),
],
),
),
],
),
);
}),
if (_editLogs.length > 10)
const Padding(
padding: EdgeInsets.only(top: 8),
child: Text(
'最新10件を表示しています。',
style: TextStyle(fontSize: 11, color: Colors.grey),
),
),
],
],
),
),
);
}
Future<void> _pickCustomer() async {
final selected = await showFeatureModalBottomSheet<Customer?>(
context: context,
builder: (ctx) => CustomerPickerModal(
onCustomerSelected: (customer) {
Navigator.pop(ctx, customer);
},
),
);
if (selected == null) return;
setState(() {
_selectedCustomer = selected;
_customerSnapshot = _withHonorific(selected.invoiceName, fallbackHonorific: selected.title);
});
_logEdit('取引先を「${selected.invoiceName}」に設定');
}
Future<void> _pickDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _issueDate,
firstDate: DateTime(2015),
lastDate: DateTime(2100),
);
if (picked == null) return;
setState(() => _issueDate = picked);
_logEdit('計上日を${_dateFormat.format(picked)}に更新');
}
Future<void> _pickSettlementDueDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _settlementDueDate ?? DateTime.now(),
firstDate: DateTime(2015),
lastDate: DateTime(2100),
);
if (picked == null) return;
setState(() => _settlementDueDate = picked);
_scheduleHistorySnapshot();
}
void _addLine() {
setState(() {
final form = LineItemFormData(quantity: 1);
_attachLineListeners(form);
_lines.add(form);
});
_pushHistory(clearRedo: true);
_logEdit('明細行を追加しました');
}
void _removeLine(int index) {
if (_lines.length <= 1) return;
final removed = _lines[index].descriptionController.text;
final target = _lines.removeAt(index);
target.removeChangeListener(_scheduleHistorySnapshot);
target.dispose();
setState(() {});
_pushHistory(clearRedo: true);
_logEdit(removed.isEmpty ? '明細行を削除しました' : '明細「$removed」を削除しました');
}
Future<void> _save() async {
if (_isSaving) return;
for (var i = 0; i < _lines.length; i++) {
if (!_lines[i].hasProduct) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('明細${i + 1}の商品を選択してください')));
return;
}
}
final subject = _subjectController.text.trim();
final notes = _notesController.text.trim();
if (_cashSaleMode && (_customerSnapshot == null || _customerSnapshot!.isEmpty)) {
_customerSnapshot = _cashSaleLabel;
} else if (!_cashSaleMode) {
_customerSnapshot = _withHonorific(_customerSnapshot);
}
final entryId = _ensureEntryId();
final lines = <SalesLineItem>[];
for (final line in _lines) {
final desc = line.descriptionController.text.trim();
final qty = int.tryParse(line.quantityController.text) ?? 0;
final price = int.tryParse(line.unitPriceController.text) ?? 0;
if (desc.isEmpty || qty <= 0) continue;
final id = line.id ?? _uuid.v4();
lines.add(
SalesLineItem(
id: id,
salesEntryId: entryId,
productId: line.productId,
description: desc,
quantity: qty,
unitPrice: price,
lineTotal: qty * price,
taxRate: line.taxRate ?? 0.1,
costAmount: line.costAmount,
costIsProvisional: line.costIsProvisional,
),
);
}
if (lines.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('明細を1件以上入力してください')));
return;
}
final base = widget.entry ??
SalesEntry(
id: entryId,
customerId: _selectedCustomer?.id,
customerNameSnapshot: _customerSnapshot,
subject: subject.isEmpty ? null : subject,
issueDate: _issueDate,
status: _status,
notes: notes.isEmpty ? null : notes,
settlementMethod: _settlementMethod,
settlementCardCompany: _cardCompanyController.text.trim().isEmpty ? null : _cardCompanyController.text.trim(),
settlementDueDate: _settlementDueDate,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
items: lines,
);
final updated = base.copyWith(
customerId: _selectedCustomer?.id ?? base.customerId,
customerNameSnapshot: _customerSnapshot ?? base.customerNameSnapshot,
subject: subject.isEmpty ? null : subject,
issueDate: _issueDate,
notes: notes.isEmpty ? null : notes,
status: _status,
items: lines,
updatedAt: DateTime.now(),
settlementMethod: _settlementMethod,
settlementCardCompany: _settlementMethod == SettlementMethod.card
? (_cardCompanyController.text.trim().isEmpty ? null : _cardCompanyController.text.trim())
: null,
settlementDueDate: _settlementMethod == SettlementMethod.accountsReceivable ? _settlementDueDate : null,
);
setState(() => _isSaving = true);
try {
final saved = await widget.service.saveEntry(updated);
if (!mounted) return;
_entryId = saved.id;
_logEdit('売上伝票を保存しました');
Navigator.pop(context, saved);
} catch (e) {
if (!mounted) return;
setState(() => _isSaving = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存に失敗しました: $e')));
}
}
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
final keyboardInset = mediaQuery.viewInsets.bottom;
final safeBottom = mediaQuery.padding.bottom;
final scrollPadding = (keyboardInset > 0 ? keyboardInset : 0) + 48.0;
return Scaffold(
backgroundColor: Colors.grey.shade200,
resizeToAvoidBottomInset: false,
appBar: AppBar(
leading: _buildBackButton(),
title: Tooltip(
message: '長押しで表示モード設定ドロワーを開きます',
child: InkWell(
onLongPress: _openQuickSettingsDrawer,
borderRadius: BorderRadius.circular(8),
child: ScreenAppBarTitle(
screenId: 'U2',
title: widget.entry == null ? '売上伝票作成' : '売上伝票編集',
),
),
),
actions: [
IconButton(
icon: const Icon(Icons.undo),
tooltip: '元に戻す',
onPressed: _canUndo ? _undo : null,
),
IconButton(
icon: const Icon(Icons.redo),
tooltip: 'やり直す',
onPressed: _canRedo ? _redo : null,
),
IconButton(
icon: const Icon(Icons.save_outlined),
tooltip: '保存',
onPressed: _isSaving ? null : _save,
),
TextButton(onPressed: _isSaving ? null : _save, child: const Text('保存')),
],
),
body: SafeArea(
top: true,
bottom: false,
child: AnimatedPadding(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
padding: EdgeInsets.fromLTRB(16, 16, 16, 32 + safeBottom),
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: EdgeInsets.only(bottom: scrollPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Card(
color: Colors.white,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: TextField(
controller: _subjectController,
decoration: const InputDecoration(labelText: '件名', border: InputBorder.none),
),
),
),
const SizedBox(height: 12),
Card(
color: Colors.white,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Expanded(
child: InkWell(
onTap: _pickDate,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text('計上日: ${_dateFormat.format(_issueDate)}'),
),
),
),
TextButton(onPressed: _pickDate, child: const Text('日付を選択')),
],
),
),
),
const SizedBox(height: 12),
Card(
color: Colors.white,
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
title: Text(_customerSnapshot ?? '顧客を選択'),
trailing: const Icon(Icons.chevron_right),
onTap: _cashSaleMode ? null : _pickCustomer,
),
),
const SizedBox(height: 12),
_buildSettlementCard(),
const Divider(height: 32),
Text('明細', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
if (_grossEnabled && _grossToggleVisible)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'粗利の表示・非表示はタイトル長押しの表示モードドロワーから切り替えられます。',
style: Theme.of(context).textTheme.bodySmall,
),
),
for (var i = 0; i < _lines.length; i++)
LineItemCard(
data: _lines[i],
onRemove: () => _removeLine(i),
onPickProduct: () => _pickProductForLine(i),
meta: _shouldShowGross ? _buildLineMeta(_lines[i]) : null,
footer: _shouldShowGross ? _buildLineFooter(_lines[i]) : null,
),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: _addLine,
icon: const Icon(Icons.add),
label: const Text('明細を追加'),
),
),
const Divider(height: 32),
if (_shouldShowGross) _buildGrossSummary(),
if (_shouldShowGross) const Divider(height: 32),
Card(
color: Colors.white,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: TextField(
controller: _notesController,
decoration: const InputDecoration(labelText: 'メモ', border: InputBorder.none),
maxLines: 3,
),
),
),
_buildEditLogPanel(),
const SizedBox(height: 80),
],
),
);
},
),
),
),
);
}
void _pickProductForLine(int index) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => FractionallySizedBox(
heightFactor: 0.9,
child: ProductPickerModal(
onProductSelected: (product) {
setState(() {
final line = _lines[index];
line.applyProduct(product);
});
_logEdit('明細${index + 1}を商品「${product.name}」に設定');
_pushHistory(clearRedo: true);
},
onItemSelected: (item) {},
),
),
);
}
void _toggleCashSaleMode(bool enabled, {bool userAction = true}) {
if (_cashSaleMode == enabled) return;
setState(() {
_cashSaleMode = enabled;
if (enabled) {
_selectedCustomer = null;
_customerSnapshot = _cashSaleLabel;
} else {
_customerSnapshot = _withHonorific(
_selectedCustomer?.invoiceName,
fallbackHonorific: _selectedCustomer?.title,
);
}
if (userAction) {
_cashSaleModeUserOverride = true;
}
});
if (userAction) {
_logEdit(enabled ? '現金売上モードを有効化' : '現金売上モードを無効化');
_pushHistory(clearRedo: true);
}
}
void _setShowGross(bool enabled, {bool userAction = true}) {
if (!_grossEnabled || _showGross == enabled) return;
setState(() {
_showGross = enabled;
if (userAction) {
_showGrossUserOverride = true;
}
});
if (userAction) {
_settingsRepo.setSalesEntryShowGross(enabled);
}
}
void _setGrossIncludeProvisional(bool include) {
if (_grossIncludeProvisional == include) return;
setState(() => _grossIncludeProvisional = include);
_settingsRepo.setGrossProfitIncludeProvisional(include);
}
Future<void> _openQuickSettingsDrawer() async {
if (!mounted || _isQuickSettingsDrawerOpen) return;
_isQuickSettingsDrawerOpen = true;
final rootContext = context;
await showGeneralDialog(
context: context,
barrierLabel: '表示モード設定',
barrierDismissible: true,
barrierColor: Colors.black54,
transitionDuration: const Duration(milliseconds: 260),
pageBuilder: (dialogContext, animation, secondaryAnimation) {
final theme = Theme.of(dialogContext);
return SafeArea(
child: Align(
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.all(16),
child: Material(
color: theme.colorScheme.surface,
elevation: 6,
borderRadius: BorderRadius.circular(20),
clipBehavior: Clip.antiAlias,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: _buildQuickSettingsContent(dialogContext, rootContext),
),
),
),
),
);
},
transitionBuilder: (context, animation, secondaryAnimation, child) {
final curved = CurvedAnimation(parent: animation, curve: Curves.easeOutCubic, reverseCurve: Curves.easeInCubic);
return SlideTransition(
position: Tween(begin: const Offset(0, -1), end: Offset.zero).animate(curved),
child: FadeTransition(opacity: curved, child: child),
);
},
);
_isQuickSettingsDrawerOpen = false;
}
Widget _buildQuickSettingsContent(BuildContext dialogContext, BuildContext rootContext) {
final textTheme = Theme.of(dialogContext).textTheme;
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: const [
Icon(Icons.tune),
SizedBox(width: 8),
Text('表示モード設定', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
],
),
const SizedBox(height: 8),
Text('ここで切り替えた内容はこの伝票に即時反映されます。', style: textTheme.bodySmall),
const Divider(height: 24),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text('現金売上モード'),
subtitle: const Text('顧客未選択で「現金売上」として登録します'),
value: _cashSaleMode,
onChanged: (value) => _toggleCashSaleMode(value),
),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text('粗利を表示'),
subtitle: const Text('各明細の粗利チップとサマリを表示します'),
value: _shouldShowGross,
onChanged: _grossEnabled ? (value) => _setShowGross(value) : null,
),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text('暫定粗利を合計に含める'),
subtitle: const Text('仕入未確定(粗利=0扱いの明細を粗利合計に含めます'),
value: _grossIncludeProvisional,
onChanged: (value) => _setGrossIncludeProvisional(value),
),
const SizedBox(height: 12),
Text(
'S1 > U2エディタ表示モード で既定値を変更すると、新規伝票の初期状態が更新されます。',
style: textTheme.bodySmall,
),
const SizedBox(height: 12),
FilledButton.icon(
icon: const Icon(Icons.settings),
label: const Text('S1:設定で既定値を編集'),
onPressed: () {
Navigator.of(dialogContext).pop();
Navigator.of(rootContext).push(MaterialPageRoute(builder: (_) => const SettingsScreen()));
},
),
],
),
);
}
bool get _shouldShowGross => _grossEnabled && _showGross;
Widget _buildSettlementCard() {
final showCardCompany = _settlementMethod == SettlementMethod.card;
final showDueDate = _settlementMethod == SettlementMethod.accountsReceivable;
return Card(
color: Colors.white,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<SettlementMethod>(
initialValue: _settlementMethod,
decoration: const InputDecoration(labelText: '清算方法'),
items: SettlementMethod.values
.map((method) => DropdownMenuItem(value: method, child: Text(method.displayName)))
.toList(),
onChanged: (value) {
setState(() {
_settlementMethod = value;
if (value != SettlementMethod.card) {
_cardCompanyController.clear();
}
if (value != SettlementMethod.accountsReceivable) {
_settlementDueDate = null;
}
});
},
),
if (showCardCompany) ...[
const SizedBox(height: 8),
TextField(
controller: _cardCompanyController,
decoration: const InputDecoration(labelText: 'カード会社'),
),
],
if (showDueDate) ...[
const SizedBox(height: 8),
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('入金予定日'),
subtitle: Text(_settlementDueDate == null ? '未設定' : _dateFormat.format(_settlementDueDate!)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_settlementDueDate != null)
IconButton(
tooltip: 'クリア',
icon: const Icon(Icons.clear),
onPressed: () => setState(() => _settlementDueDate = null),
),
TextButton(onPressed: _pickSettlementDueDate, child: const Text('選択')),
],
),
),
],
],
),
),
);
}
int _lineQuantity(LineItemFormData line) => int.tryParse(line.quantityController.text) ?? 0;
int _lineUnitPrice(LineItemFormData line) => int.tryParse(line.unitPriceController.text) ?? 0;
int _lineRevenue(LineItemFormData line) => _lineQuantity(line) * _lineUnitPrice(line);
int _lineCost(LineItemFormData line) => _lineQuantity(line) * line.costAmount;
int _lineGross(LineItemFormData line) {
if (_isProvisional(line)) return 0;
return _lineRevenue(line) - _lineCost(line);
}
bool _isProvisional(LineItemFormData line) => line.costIsProvisional || line.costAmount <= 0;
String _formatYen(int value) => _currencyFormat.format(value).replaceAll('.00', '');
Widget _buildLineMeta(LineItemFormData line) {
final gross = _lineGross(line);
final provisional = _isProvisional(line);
final color = provisional
? Colors.orange
: gross >= 0
? Colors.green
: Colors.redAccent;
final label = provisional ? '粗利(暫定0円)' : '粗利';
return Padding(
padding: const EdgeInsets.only(right: 8),
child: Chip(
label: Text('$label ${_formatYen(gross)}'),
backgroundColor: color.withValues(alpha: 0.12),
labelStyle: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w600),
),
);
}
Widget _buildLineFooter(LineItemFormData line) {
final cost = _lineCost(line);
final provisional = _isProvisional(line);
final text = provisional
? '仕入: ${_formatYen(cost)} (粗利は暫定0円)'
: '仕入: ${_formatYen(cost)}';
return Align(
alignment: Alignment.centerRight,
child: Text(
text,
style: TextStyle(
fontSize: 12,
color: provisional ? Colors.orange.shade700 : Colors.black54,
),
),
);
}
int _grossTotal({required bool includeProvisional}) {
var total = 0;
for (final line in _lines) {
if (!includeProvisional && _isProvisional(line)) continue;
total += _lineGross(line);
}
return total;
}
int _provisionalCount() => _lines.where(_isProvisional).length;
Widget _buildGrossSummary() {
final total = _grossTotal(includeProvisional: _grossIncludeProvisional);
final excluded = _grossTotal(includeProvisional: false);
final provisionalLines = _provisionalCount();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('粗利サマリ', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _SummaryTile(
label: _grossIncludeProvisional ? '粗利合計(暫定含む)' : '粗利合計',
value: _formatYen(total),
valueColor: total >= 0 ? Colors.green.shade700 : Colors.redAccent,
),
),
const SizedBox(width: 12),
Expanded(
child: _SummaryTile(
label: '暫定を除いた粗利',
value: _formatYen(excluded),
),
),
],
),
if (provisionalLines > 0)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'暫定粗利の明細: $provisionalLines 件 (設定で合計への含め方を変更できます)',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.orange.shade700),
),
),
],
);
}
}
class _SummaryTile extends StatelessWidget {
const _SummaryTile({required this.label, required this.value, this.valueColor});
final String label;
final String value;
final Color? valueColor;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: theme.textTheme.bodySmall),
const SizedBox(height: 4),
Text(
value,
style: theme.textTheme.titleMedium?.copyWith(
color: valueColor ?? theme.colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
),
],
),
);
}
}
class SalesEntryImportSheet extends StatefulWidget {
const SalesEntryImportSheet({required this.service, super.key});
final SalesEntryService service;
static Future<SalesEntry?> show(BuildContext context, SalesEntryService service) {
return showModalBottomSheet<SalesEntry>(
context: context,
isScrollControlled: true,
builder: (_) => DraggableScrollableSheet(
expand: false,
initialChildSize: 0.85,
builder: (_, controller) => SalesEntryImportSheet(service: service),
),
);
}
@override
State<SalesEntryImportSheet> createState() => _SalesEntryImportSheetState();
}
class _SalesEntryImportSheetState extends State<SalesEntryImportSheet> {
final TextEditingController _keywordController = TextEditingController();
final TextEditingController _subjectController = TextEditingController();
bool _isLoading = true;
bool _isImporting = false;
List<SalesImportCandidate> _candidates = const [];
Set<String> _selected = {};
Set<DocumentType> _types = DocumentType.values.toSet();
DateTime? _startDate;
DateTime? _endDate;
DateTime? _issueDateOverride;
@override
void initState() {
super.initState();
_loadCandidates();
}
@override
void dispose() {
_keywordController.dispose();
_subjectController.dispose();
super.dispose();
}
Future<void> _loadCandidates() async {
setState(() => _isLoading = true);
try {
final results = await widget.service.fetchImportCandidates(
keyword: _keywordController.text.trim().isEmpty ? null : _keywordController.text.trim(),
documentTypes: _types,
startDate: _startDate,
endDate: _endDate,
);
if (!mounted) return;
setState(() {
_candidates = results;
_isLoading = false;
_selected = _selected.where((id) => results.any((c) => c.invoiceId == id)).toSet();
});
} catch (e) {
if (!mounted) return;
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('インポート候補の取得に失敗しました: $e')));
}
}
Future<void> _pickRange({required bool isStart}) async {
final initial = isStart ? (_startDate ?? DateTime.now().subtract(const Duration(days: 30))) : (_endDate ?? DateTime.now());
final picked = await showDatePicker(
context: context,
initialDate: initial,
firstDate: DateTime(2015),
lastDate: DateTime(2100),
);
if (picked == null) return;
setState(() {
if (isStart) {
_startDate = picked;
} else {
_endDate = picked;
}
});
_loadCandidates();
}
Future<void> _pickIssueDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _issueDateOverride ?? DateTime.now(),
firstDate: DateTime(2015),
lastDate: DateTime(2100),
);
if (picked == null) return;
setState(() => _issueDateOverride = picked);
}
void _toggleType(DocumentType type) {
setState(() {
if (_types.contains(type)) {
_types.remove(type);
if (_types.isEmpty) {
_types = {type};
}
} else {
_types.add(type);
}
});
_loadCandidates();
}
void _toggleSelection(String invoiceId) {
setState(() {
if (_selected.contains(invoiceId)) {
_selected.remove(invoiceId);
} else {
_selected.add(invoiceId);
}
});
}
Future<void> _importSelected() async {
if (_selected.isEmpty || _isImporting) return;
setState(() => _isImporting = true);
try {
final entry = await widget.service.createEntryFromInvoices(
_selected.toList(),
subject: _subjectController.text.trim().isEmpty ? null : _subjectController.text.trim(),
issueDate: _issueDateOverride,
);
if (!mounted) return;
Navigator.pop(context, entry);
} catch (e) {
if (!mounted) return;
setState(() => _isImporting = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('インポートに失敗しました: $e')));
}
}
@override
Widget build(BuildContext context) {
final dateFormat = DateFormat('yyyy/MM/dd');
return Material(
color: Theme.of(context).scaffoldBackgroundColor,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Expanded(child: Text('伝票インポート', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold))),
IconButton(onPressed: () => Navigator.pop(context), icon: const Icon(Icons.close)),
],
),
const SizedBox(height: 8),
TextField(
controller: _keywordController,
decoration: InputDecoration(
labelText: 'キーワード (件名/顧客)',
suffixIcon: IconButton(icon: const Icon(Icons.search), onPressed: _loadCandidates),
),
onSubmitted: (_) => _loadCandidates(),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
children: DocumentType.values
.map((type) => FilterChip(
label: Text(type.displayName),
selected: _types.contains(type),
onSelected: (_) => _toggleType(type),
))
.toList(),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: Text('開始日: ${_startDate != null ? dateFormat.format(_startDate!) : '指定なし'}')),
TextButton(onPressed: () => _pickRange(isStart: true), child: const Text('開始日を選択')),
],
),
Row(
children: [
Expanded(child: Text('終了日: ${_endDate != null ? dateFormat.format(_endDate!) : '指定なし'}')),
TextButton(onPressed: () => _pickRange(isStart: false), child: const Text('終了日を選択')),
],
),
const Divider(height: 24),
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _candidates.isEmpty
? const Center(child: Text('条件に合致する伝票が見つかりません'))
: ListView.builder(
itemCount: _candidates.length,
itemBuilder: (context, index) {
final candidate = _candidates[index];
final selected = _selected.contains(candidate.invoiceId);
return CheckboxListTile(
value: selected,
onChanged: (_) => _toggleSelection(candidate.invoiceId),
title: Text(candidate.subject ?? '${candidate.documentTypeName}(${candidate.invoiceNumber})'),
subtitle: Text(
'${candidate.documentTypeName} / ${candidate.customerName}\n${dateFormat.format(candidate.invoiceDate)} / 合計: ${NumberFormat.currency(locale: 'ja_JP', symbol: '¥').format(candidate.totalAmount)}',
),
);
},
),
),
const Divider(height: 24),
TextField(
controller: _subjectController,
decoration: const InputDecoration(labelText: '売上伝票の件名 (任意)'),
),
Row(
children: [
Expanded(child: Text('売上伝票の日付: ${_issueDateOverride != null ? dateFormat.format(_issueDateOverride!) : '自動設定'}')),
TextButton(onPressed: _pickIssueDate, child: const Text('変更')),
],
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _selected.isEmpty || _isImporting ? null : _importSelected,
icon: _isImporting ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.playlist_add),
label: Text(_isImporting ? 'インポート中...' : '選択(${_selected.length})件をインポート'),
),
),
],
),
),
);
}
}