分割の後片付け終了
This commit is contained in:
parent
7baba0091b
commit
39759be02a
7 changed files with 499 additions and 326 deletions
|
|
@ -299,15 +299,17 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
|||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
return AlertDialog(
|
||||
contentPadding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
title: Text(isEdit ? "顧客を編集" : "顧客を新規登録"),
|
||||
content: KeyboardInsetWrapper(
|
||||
basePadding: const EdgeInsets.fromLTRB(0, 0, 0, 12),
|
||||
extraBottom: 32,
|
||||
child: SingleChildScrollView(
|
||||
final inset = MediaQuery.of(context).viewInsets.bottom;
|
||||
return MediaQuery.removeViewInsets(
|
||||
removeBottom: true,
|
||||
context: context,
|
||||
child: AlertDialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
|
||||
contentPadding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
title: Text(isEdit ? "顧客を編集" : "顧客を新規登録"),
|
||||
content: SingleChildScrollView(
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
padding: EdgeInsets.only(bottom: inset + 12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
|
@ -396,33 +398,34 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
|||
],
|
||||
),
|
||||
),
|
||||
actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
if (displayNameController.text.isEmpty || formalNameController.text.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final head1 = _normalizeIndexChar(head1Controller.text);
|
||||
final head2 = _normalizeIndexChar(head2Controller.text);
|
||||
final newCustomer = Customer(
|
||||
id: customer?.id ?? const Uuid().v4(),
|
||||
displayName: displayNameController.text,
|
||||
formalName: formalNameController.text,
|
||||
title: selectedTitle,
|
||||
department: departmentController.text.isEmpty ? null : departmentController.text,
|
||||
address: addressController.text.isEmpty ? null : addressController.text,
|
||||
tel: telController.text.isEmpty ? null : telController.text,
|
||||
headChar1: head1.isEmpty ? _headKana(displayNameController.text) : head1,
|
||||
headChar2: head2.isEmpty ? null : head2,
|
||||
isLocked: customer?.isLocked ?? false,
|
||||
);
|
||||
Navigator.pop(context, newCustomer);
|
||||
},
|
||||
child: const Text("保存"),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
if (displayNameController.text.isEmpty || formalNameController.text.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final head1 = _normalizeIndexChar(head1Controller.text);
|
||||
final head2 = _normalizeIndexChar(head2Controller.text);
|
||||
final newCustomer = Customer(
|
||||
id: customer?.id ?? const Uuid().v4(),
|
||||
displayName: displayNameController.text,
|
||||
formalName: formalNameController.text,
|
||||
title: selectedTitle,
|
||||
department: departmentController.text.isEmpty ? null : departmentController.text,
|
||||
address: addressController.text.isEmpty ? null : addressController.text,
|
||||
tel: telController.text.isEmpty ? null : telController.text,
|
||||
headChar1: head1.isEmpty ? _headKana(displayNameController.text) : head1,
|
||||
headChar2: head2.isEmpty ? null : head2,
|
||||
isLocked: customer?.isLocked ?? false,
|
||||
);
|
||||
Navigator.pop(context, newCustomer);
|
||||
},
|
||||
child: const Text("保存"),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -663,6 +666,7 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: AppBar(
|
||||
leading: const BackButton(),
|
||||
title: Text(widget.selectionMode ? "C2:顧客選択" : "C1:顧客一覧"),
|
||||
|
|
@ -712,82 +716,98 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
|||
),
|
||||
],
|
||||
),
|
||||
body: KeyboardInsetWrapper(
|
||||
basePadding: const EdgeInsets.fromLTRB(0, 8, 0, 80),
|
||||
extraBottom: 40,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.selectionMode ? "名前で検索して選択" : "名前で検索 (電話帳参照ボタンは詳細で)",
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 8),
|
||||
child: CustomScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.selectionMode ? "名前で検索して選択" : "名前で検索 (電話帳参照ボタンは詳細で)",
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
|
||||
),
|
||||
onChanged: (_) => setState(_applyFilter),
|
||||
),
|
||||
onChanged: (_) => setState(_applyFilter),
|
||||
),
|
||||
),
|
||||
if (!widget.selectionMode)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: SwitchListTile(
|
||||
title: const Text('株式会社/有限会社などの接頭辞を無視してソート'),
|
||||
value: _ignoreCorpPrefix,
|
||||
onChanged: (v) => setState(() {
|
||||
_ignoreCorpPrefix = v;
|
||||
_applyFilter();
|
||||
}),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: SwitchListTile(
|
||||
title: const Text('株式会社/有限会社などの接頭辞を無視してソート'),
|
||||
value: _ignoreCorpPrefix,
|
||||
onChanged: (v) => setState(() {
|
||||
_ignoreCorpPrefix = v;
|
||||
_applyFilter();
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _filtered.isEmpty
|
||||
? const Center(child: Text("顧客が登録されていません"))
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.only(bottom: 120, top: 4),
|
||||
itemCount: _filtered.length,
|
||||
itemBuilder: (context, index) {
|
||||
final c = _filtered[index];
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: c.isLocked ? Colors.grey.shade300 : Colors.indigo.shade100,
|
||||
child: Stack(
|
||||
children: [
|
||||
const Align(alignment: Alignment.center, child: Icon(Icons.person, color: Colors.indigo)),
|
||||
if (c.isLocked)
|
||||
const Align(alignment: Alignment.bottomRight, child: Icon(Icons.lock, size: 14, color: Colors.redAccent)),
|
||||
],
|
||||
),
|
||||
),
|
||||
title: Text(c.displayName, style: TextStyle(fontWeight: FontWeight.bold, color: c.isLocked ? Colors.grey : Colors.black87)),
|
||||
subtitle: Text("${c.formalName} ${c.title}"),
|
||||
onTap: widget.selectionMode ? () => Navigator.pop(context, c) : () => _showDetailPane(c),
|
||||
trailing: widget.selectionMode
|
||||
? null
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: c.isLocked ? null : () => _addOrEditCustomer(customer: c),
|
||||
tooltip: c.isLocked ? "ロック中" : "編集",
|
||||
),
|
||||
onLongPress: () => _showContextActions(c),
|
||||
);
|
||||
},
|
||||
if (_isLoading)
|
||||
const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (_filtered.isEmpty)
|
||||
const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(child: Text("顧客が登録されていません")),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.only(bottom: 80, top: 4),
|
||||
sliver: SliverList.builder(
|
||||
itemCount: _filtered.length,
|
||||
itemBuilder: (context, index) {
|
||||
final c = _filtered[index];
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: c.isLocked ? Colors.grey.shade300 : Colors.indigo.shade100,
|
||||
child: Stack(
|
||||
children: [
|
||||
const Align(alignment: Alignment.center, child: Icon(Icons.person, color: Colors.indigo)),
|
||||
if (c.isLocked)
|
||||
const Align(alignment: Alignment.bottomRight, child: Icon(Icons.lock, size: 14, color: Colors.redAccent)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(c.displayName, style: TextStyle(fontWeight: FontWeight.bold, color: c.isLocked ? Colors.grey : Colors.black87)),
|
||||
subtitle: Text("${c.formalName} ${c.title}"),
|
||||
onTap: widget.selectionMode ? () => Navigator.pop(context, c) : () => _showDetailPane(c),
|
||||
trailing: widget.selectionMode
|
||||
? null
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: c.isLocked ? null : () => _addOrEditCustomer(customer: c),
|
||||
tooltip: c.isLocked ? "ロック中" : "編集",
|
||||
),
|
||||
onLongPress: () => _showContextActions(c),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _showAddMenu,
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(widget.selectionMode ? "選択" : "追加"),
|
||||
backgroundColor: Colors.indigo,
|
||||
foregroundColor: Colors.white,
|
||||
floatingActionButton: Builder(
|
||||
builder: (context) {
|
||||
return FloatingActionButton.extended(
|
||||
onPressed: _showAddMenu,
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(widget.selectionMode ? "選択" : "追加"),
|
||||
backgroundColor: Colors.indigo,
|
||||
foregroundColor: Colors.white,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -222,84 +222,93 @@ class _CustomerPickerModalState extends State<CustomerPickerModal> {
|
|||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
child: KeyboardInsetWrapper(
|
||||
basePadding: const EdgeInsets.fromLTRB(0, 0, 0, 24),
|
||||
extraBottom: 24,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text("顧客マスター管理", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: "登録済み顧客を検索...",
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
basePadding: const EdgeInsets.fromLTRB(0, 0, 0, 16),
|
||||
extraBottom: 32,
|
||||
child: CustomScrollView(
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text("顧客マスター管理", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)),
|
||||
],
|
||||
),
|
||||
onChanged: _onSearch,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isImportingFromContacts ? null : _importFromPhoneContacts,
|
||||
icon: _isImportingFromContacts
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.contact_phone),
|
||||
label: const Text("電話帳から新規取り込み"),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.blueGrey.shade700, foregroundColor: Colors.white),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: "登録済み顧客を検索...",
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
onChanged: _onSearch,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isImportingFromContacts ? null : _importFromPhoneContacts,
|
||||
icon: _isImportingFromContacts
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.contact_phone),
|
||||
label: const Text("電話帳から新規取り込み"),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.blueGrey.shade700, foregroundColor: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _filteredCustomers.isEmpty
|
||||
? const Center(child: Text("該当する顧客がいません"))
|
||||
: ListView.builder(
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
padding: const EdgeInsets.only(bottom: 80),
|
||||
itemCount: _filteredCustomers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final customer = _filteredCustomers[index];
|
||||
return ListTile(
|
||||
leading: const CircleAvatar(child: Icon(Icons.business)),
|
||||
title: Text(customer.formalName),
|
||||
subtitle: Text(customer.department?.isNotEmpty == true ? customer.department! : "部署未設定"),
|
||||
onTap: () => widget.onCustomerSelected(customer),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, color: Colors.blueGrey, size: 20),
|
||||
onPressed: () => _showCustomerEditDialog(
|
||||
displayName: customer.displayName,
|
||||
initialFormalName: customer.formalName,
|
||||
existingCustomer: customer,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline, color: Colors.redAccent, size: 20),
|
||||
onPressed: () => _confirmDelete(customer),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: Divider(height: 1)),
|
||||
if (_isLoading)
|
||||
const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (_filteredCustomers.isEmpty)
|
||||
const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(child: Text("該当する顧客がいません")),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.only(bottom: 120),
|
||||
sliver: SliverList.builder(
|
||||
itemCount: _filteredCustomers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final customer = _filteredCustomers[index];
|
||||
return ListTile(
|
||||
leading: const CircleAvatar(child: Icon(Icons.business)),
|
||||
title: Text(customer.formalName),
|
||||
subtitle: Text(customer.department?.isNotEmpty == true ? customer.department! : "部署未設定"),
|
||||
onTap: () => widget.onCustomerSelected(customer),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, color: Colors.blueGrey, size: 20),
|
||||
onPressed: () => _showCustomerEditDialog(
|
||||
displayName: customer.displayName,
|
||||
initialFormalName: customer.formalName,
|
||||
existingCustomer: customer,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline, color: Colors.redAccent, size: 20),
|
||||
onPressed: () => _confirmDelete(customer),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import 'invoice_input_screen.dart';
|
|||
import 'settings_screen.dart';
|
||||
import 'company_info_screen.dart';
|
||||
import '../widgets/slide_to_unlock.dart';
|
||||
import '../main.dart'; // InvoiceFlowScreen 用
|
||||
// InvoiceFlowScreen import removed; using inline type picker
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import '../widgets/invoice_pdf_preview_page.dart';
|
||||
import 'invoice_history/invoice_history_list.dart';
|
||||
|
|
@ -375,15 +375,7 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
|||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _isUnlocked
|
||||
? () async {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => InvoiceFlowScreen(onComplete: _loadData),
|
||||
),
|
||||
);
|
||||
_loadData();
|
||||
}
|
||||
? () => _showCreateTypeMenu()
|
||||
: _requireUnlock,
|
||||
label: const Text("新規伝票作成"),
|
||||
icon: const Icon(Icons.add),
|
||||
|
|
@ -392,4 +384,51 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showCreateTypeMenu() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.insert_drive_file_outlined),
|
||||
title: const Text('下書き: 見積書', style: TextStyle(fontSize: 24)),
|
||||
onTap: () => _startNew(DocumentType.estimation),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.local_shipping_outlined),
|
||||
title: const Text('下書き: 納品書', style: TextStyle(fontSize: 24)),
|
||||
onTap: () => _startNew(DocumentType.delivery),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.request_quote_outlined),
|
||||
title: const Text('下書き: 請求書', style: TextStyle(fontSize: 24)),
|
||||
onTap: () => _startNew(DocumentType.invoice),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.receipt_long_outlined),
|
||||
title: const Text('下書き: 領収書', style: TextStyle(fontSize: 24)),
|
||||
onTap: () => _startNew(DocumentType.receipt),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _startNew(DocumentType type) async {
|
||||
Navigator.pop(context);
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => InvoiceInputForm(
|
||||
onInvoiceGenerated: (inv, path) {},
|
||||
initialDocumentType: type,
|
||||
),
|
||||
),
|
||||
);
|
||||
_loadData();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,17 +9,19 @@ import '../widgets/invoice_pdf_preview_page.dart';
|
|||
import 'invoice_detail_page.dart';
|
||||
import '../services/gps_service.dart';
|
||||
import 'customer_master_screen.dart';
|
||||
import 'product_picker_modal.dart';
|
||||
import '../widgets/keyboard_inset_wrapper.dart';
|
||||
import 'product_master_screen.dart';
|
||||
import '../models/product_model.dart';
|
||||
|
||||
class InvoiceInputForm extends StatefulWidget {
|
||||
final Function(Invoice invoice, String filePath) onInvoiceGenerated;
|
||||
final Invoice? existingInvoice; // 追加: 編集時の既存伝票
|
||||
final DocumentType initialDocumentType;
|
||||
|
||||
const InvoiceInputForm({
|
||||
super.key,
|
||||
required this.onInvoiceGenerated,
|
||||
this.existingInvoice, // 追加
|
||||
this.initialDocumentType = DocumentType.invoice,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -72,21 +74,26 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
_taxRate = 0;
|
||||
_includeTax = false;
|
||||
_isDraft = true;
|
||||
_documentType = widget.initialDocumentType;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _addItem() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => ProductPickerModal(
|
||||
onItemSelected: (item) {
|
||||
setState(() => _items.add(item));
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
);
|
||||
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,
|
||||
));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
int get _subTotal => _items.fold(0, (sum, item) => sum + (item.unitPrice * item.quantity));
|
||||
|
|
@ -215,42 +222,33 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
KeyboardInsetWrapper(
|
||||
basePadding: const EdgeInsets.fromLTRB(0, 0, 0, 0),
|
||||
extraBottom: 24,
|
||||
child: InteractiveViewer(
|
||||
panEnabled: false,
|
||||
minScale: 0.8,
|
||||
maxScale: 2.5,
|
||||
clipBehavior: Clip.none,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 160),
|
||||
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: 20),
|
||||
_buildSignatureSection(),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
),
|
||||
),
|
||||
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: 20),
|
||||
_buildSignatureSection(),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
),
|
||||
_buildBottomActionBar(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildBottomActionBar(),
|
||||
],
|
||||
),
|
||||
if (_isSaving)
|
||||
Container(
|
||||
|
|
@ -404,18 +402,12 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
TextButton.icon(
|
||||
icon: const Icon(Icons.search, size: 18),
|
||||
label: const Text("マスター参照"),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => ProductPickerModal(
|
||||
onItemSelected: (selected) {
|
||||
descCtrl.text = selected.description;
|
||||
priceCtrl.text = selected.unitPrice.toString();
|
||||
Navigator.pop(context); // close picker
|
||||
},
|
||||
),
|
||||
);
|
||||
onPressed: () async {
|
||||
Navigator.pop(context); // close edit dialog before jumping
|
||||
await Navigator.push(
|
||||
this.context,
|
||||
MaterialPageRoute(builder: (_) => const ProductMasterScreen()),
|
||||
);
|
||||
},
|
||||
),
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ import 'package:uuid/uuid.dart';
|
|||
import '../models/product_model.dart';
|
||||
import '../services/product_repository.dart';
|
||||
import 'barcode_scanner_screen.dart';
|
||||
import '../widgets/keyboard_inset_wrapper.dart';
|
||||
|
||||
class ProductMasterScreen extends StatefulWidget {
|
||||
const ProductMasterScreen({super.key});
|
||||
final bool selectionMode;
|
||||
|
||||
const ProductMasterScreen({super.key, this.selectionMode = false});
|
||||
|
||||
@override
|
||||
State<ProductMasterScreen> createState() => _ProductMasterScreenState();
|
||||
|
|
@ -59,65 +60,71 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
|
|||
final result = await showDialog<Product>(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setDialogState) => AlertDialog(
|
||||
title: Text(product == null ? "商品追加" : "商品編集"),
|
||||
content: KeyboardInsetWrapper(
|
||||
basePadding: EdgeInsets.zero,
|
||||
extraBottom: 16,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(controller: nameController, decoration: const InputDecoration(labelText: "商品名")),
|
||||
TextField(controller: categoryController, decoration: const InputDecoration(labelText: "カテゴリ")),
|
||||
TextField(controller: priceController, decoration: const InputDecoration(labelText: "初期単価"), keyboardType: TextInputType.number),
|
||||
TextField(controller: stockController, decoration: const InputDecoration(labelText: "在庫数"), keyboardType: TextInputType.number),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(controller: barcodeController, decoration: const InputDecoration(labelText: "バーコード")),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.qr_code_scanner),
|
||||
onPressed: () async {
|
||||
final code = await Navigator.push<String>(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const BarcodeScannerScreen()),
|
||||
);
|
||||
if (code != null) {
|
||||
setDialogState(() => barcodeController.text = code);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
builder: (context, setDialogState) {
|
||||
final inset = MediaQuery.of(context).viewInsets.bottom;
|
||||
return MediaQuery.removeViewInsets(
|
||||
removeBottom: true,
|
||||
context: context,
|
||||
child: AlertDialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
|
||||
title: Text(product == null ? "商品追加" : "商品編集"),
|
||||
content: SingleChildScrollView(
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
padding: EdgeInsets.only(bottom: inset + 12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(controller: nameController, decoration: const InputDecoration(labelText: "商品名")),
|
||||
TextField(controller: categoryController, decoration: const InputDecoration(labelText: "カテゴリ")),
|
||||
TextField(controller: priceController, decoration: const InputDecoration(labelText: "初期単価"), keyboardType: TextInputType.number),
|
||||
TextField(controller: stockController, decoration: const InputDecoration(labelText: "在庫数"), keyboardType: TextInputType.number),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(controller: barcodeController, decoration: const InputDecoration(labelText: "バーコード")),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.qr_code_scanner),
|
||||
onPressed: () async {
|
||||
final code = await Navigator.push<String>(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const BarcodeScannerScreen()),
|
||||
);
|
||||
if (code != null) {
|
||||
setDialogState(() => barcodeController.text = code);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
if (nameController.text.isEmpty) return;
|
||||
Navigator.pop(
|
||||
context,
|
||||
Product(
|
||||
id: product?.id ?? const Uuid().v4(),
|
||||
name: nameController.text.trim(),
|
||||
defaultUnitPrice: int.tryParse(priceController.text) ?? 0,
|
||||
barcode: barcodeController.text.isEmpty ? null : barcodeController.text.trim(),
|
||||
category: categoryController.text.isEmpty ? null : categoryController.text.trim(),
|
||||
stockQuantity: int.tryParse(stockController.text) ?? 0,
|
||||
odooId: product?.odooId,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text("保存"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
if (nameController.text.isEmpty) return;
|
||||
Navigator.pop(
|
||||
context,
|
||||
Product(
|
||||
id: product?.id ?? const Uuid().v4(),
|
||||
name: nameController.text.trim(),
|
||||
defaultUnitPrice: int.tryParse(priceController.text) ?? 0,
|
||||
barcode: barcodeController.text.isEmpty ? null : barcodeController.text.trim(),
|
||||
category: categoryController.text.isEmpty ? null : categoryController.text.trim(),
|
||||
stockQuantity: int.tryParse(stockController.text) ?? 0,
|
||||
odooId: product?.odooId,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text("保存"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -131,6 +138,7 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: AppBar(
|
||||
leading: const BackButton(),
|
||||
title: const Text("P1:商品マスター"),
|
||||
|
|
@ -157,15 +165,15 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
|
|||
),
|
||||
),
|
||||
),
|
||||
body: KeyboardInsetWrapper(
|
||||
basePadding: EdgeInsets.zero,
|
||||
extraBottom: 72,
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 8),
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _filteredProducts.isEmpty
|
||||
? const Center(child: Text("商品が見つかりません"))
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.only(bottom: 120, top: 8),
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.only(bottom: 80, top: 8),
|
||||
itemCount: _filteredProducts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final p = _filteredProducts[index];
|
||||
|
|
@ -182,12 +190,63 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
|
|||
),
|
||||
title: Text(p.name, style: TextStyle(fontWeight: FontWeight.bold, color: p.isLocked ? Colors.grey : Colors.black87)),
|
||||
subtitle: Text("${p.category ?? '未分類'} - ¥${p.defaultUnitPrice} (在庫: ${p.stockQuantity})"),
|
||||
onTap: () => _showDetailPane(p),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: p.isLocked ? null : () => _showEditDialog(product: p),
|
||||
tooltip: p.isLocked ? "ロック中" : "編集",
|
||||
),
|
||||
onTap: () {
|
||||
if (widget.selectionMode) {
|
||||
Navigator.pop(context, p);
|
||||
} else {
|
||||
_showDetailPane(p);
|
||||
}
|
||||
},
|
||||
onLongPress: () async {
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.edit),
|
||||
title: const Text("編集"),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
_showEditDialog(product: p);
|
||||
},
|
||||
),
|
||||
if (!p.isLocked)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_outline, color: Colors.redAccent),
|
||||
title: const Text("削除", style: TextStyle(color: Colors.redAccent)),
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text("削除の確認"),
|
||||
content: Text("${p.name} を削除しますか?"),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("キャンセル")),
|
||||
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text("削除", style: TextStyle(color: Colors.red))),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
await _productRepo.deleteProduct(p.id);
|
||||
if (mounted) _loadProducts();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
trailing: widget.selectionMode
|
||||
? null
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: p.isLocked ? null : () => _showEditDialog(product: p),
|
||||
tooltip: p.isLocked ? "ロック中" : "編集",
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -45,12 +45,15 @@ class _ProductPickerModalState extends State<ProductPickerModal> {
|
|||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 16, 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Text("商品・サービス選択", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -101,14 +104,63 @@ class _ProductPickerModalState extends State<ProductPickerModal> {
|
|||
leading: const Icon(Icons.inventory_2_outlined),
|
||||
title: Text(product.name),
|
||||
subtitle: Text("¥${product.defaultUnitPrice} (在庫: ${product.stockQuantity})"),
|
||||
onTap: () => widget.onItemSelected(
|
||||
InvoiceItem(
|
||||
productId: product.id,
|
||||
description: product.name,
|
||||
quantity: 1,
|
||||
unitPrice: product.defaultUnitPrice,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
widget.onItemSelected(
|
||||
InvoiceItem(
|
||||
productId: product.id,
|
||||
description: product.name,
|
||||
quantity: 1,
|
||||
unitPrice: product.defaultUnitPrice,
|
||||
),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
onLongPress: () async {
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.edit),
|
||||
title: const Text("編集"),
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const ProductMasterScreen()),
|
||||
);
|
||||
_onSearch(_searchController.text);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_outline, color: Colors.redAccent),
|
||||
title: const Text("削除", style: TextStyle(color: Colors.redAccent)),
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text("削除の確認"),
|
||||
content: Text("${product.name} を削除しますか?"),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("キャンセル")),
|
||||
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text("削除", style: TextStyle(color: Colors.red))),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
await _productRepo.deleteProduct(product.id);
|
||||
if (mounted) _onSearch(_searchController.text);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../widgets/keyboard_inset_wrapper.dart';
|
||||
import 'company_info_screen.dart';
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
|
|
@ -227,6 +226,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
||||
final listBottomPadding = 24 + bottomInset;
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: AppBar(
|
||||
|
|
@ -238,11 +239,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
),
|
||||
],
|
||||
),
|
||||
body: KeyboardInsetWrapper(
|
||||
basePadding: const EdgeInsets.fromLTRB(16, 16, 16, 80),
|
||||
extraBottom: 40,
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||
child: ListView(
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: EdgeInsets.only(bottom: listBottomPadding),
|
||||
children: [
|
||||
_section(
|
||||
title: '自社情報',
|
||||
|
|
|
|||
Loading…
Reference in a new issue