From 39759be02ade6194ad83bff1465bdecb991eadbe Mon Sep 17 00:00:00 2001 From: joe Date: Fri, 27 Feb 2026 16:25:27 +0900 Subject: [PATCH] =?UTF-8?q?=E5=88=86=E5=89=B2=E3=81=AE=E5=BE=8C=E7=89=87?= =?UTF-8?q?=E4=BB=98=E3=81=91=E7=B5=82=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/screens/customer_master_screen.dart | 220 +++++++++++++----------- lib/screens/customer_picker_modal.dart | 157 +++++++++-------- lib/screens/invoice_history_screen.dart | 59 +++++-- lib/screens/invoice_input_screen.dart | 108 ++++++------ lib/screens/product_master_screen.dart | 197 +++++++++++++-------- lib/screens/product_picker_modal.dart | 74 ++++++-- lib/screens/settings_screen.dart | 10 +- 7 files changed, 499 insertions(+), 326 deletions(-) diff --git a/lib/screens/customer_master_screen.dart b/lib/screens/customer_master_screen.dart index 3e72756..bdea89f 100644 --- a/lib/screens/customer_master_screen.dart +++ b/lib/screens/customer_master_screen.dart @@ -299,15 +299,17 @@ class _CustomerMasterScreenState extends State { 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 { ], ), ), + 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 { @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 { ), ], ), - 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, + ); + }, ), ); } diff --git a/lib/screens/customer_picker_modal.dart b/lib/screens/customer_picker_modal.dart index 9d3a0d5..ce670c9 100644 --- a/lib/screens/customer_picker_modal.dart +++ b/lib/screens/customer_picker_modal.dart @@ -222,84 +222,93 @@ class _CustomerPickerModalState extends State { 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), + ), + ], + ), + ); + }, + ), + ), ], ), ), diff --git a/lib/screens/invoice_history_screen.dart b/lib/screens/invoice_history_screen.dart index 3f0733f..eb12863 100644 --- a/lib/screens/invoice_history_screen.dart +++ b/lib/screens/invoice_history_screen.dart @@ -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 { ), 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 { ), ); } + + 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 _startNew(DocumentType type) async { + Navigator.pop(context); + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => InvoiceInputForm( + onInvoiceGenerated: (inv, path) {}, + initialDocumentType: type, + ), + ), + ); + _loadData(); + } } diff --git a/lib/screens/invoice_input_screen.dart b/lib/screens/invoice_input_screen.dart index 17fe2bb..c26be4f 100644 --- a/lib/screens/invoice_input_screen.dart +++ b/lib/screens/invoice_input_screen.dart @@ -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 { _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( + 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 { ), 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 { 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("キャンセル")), diff --git a/lib/screens/product_master_screen.dart b/lib/screens/product_master_screen.dart index 0b5e5e7..4a83348 100644 --- a/lib/screens/product_master_screen.dart +++ b/lib/screens/product_master_screen.dart @@ -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 createState() => _ProductMasterScreenState(); @@ -59,65 +60,71 @@ class _ProductMasterScreenState extends State { final result = await showDialog( 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( - 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( + 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 { @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 { ), ), ), - 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 { ), 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( + 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 ? "ロック中" : "編集", + ), ); }, ), diff --git a/lib/screens/product_picker_modal.dart b/lib/screens/product_picker_modal.dart index f81df4d..328f5f5 100644 --- a/lib/screens/product_picker_modal.dart +++ b/lib/screens/product_picker_modal.dart @@ -45,12 +45,15 @@ class _ProductPickerModalState extends State { 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 { 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( + 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); + } + }, + ), + ], + ), + ), + ); + }, ); }, ), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 3353047..2f42080 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -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 { @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 { ), ], ), - 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: '自社情報',