分割の後片付け終了

This commit is contained in:
joe 2026-02-27 16:25:27 +09:00
parent 7baba0091b
commit 39759be02a
7 changed files with 499 additions and 326 deletions

View file

@ -299,15 +299,17 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
context: context, context: context,
builder: (context) => StatefulBuilder( builder: (context) => StatefulBuilder(
builder: (context, setDialogState) { builder: (context, setDialogState) {
return AlertDialog( final inset = MediaQuery.of(context).viewInsets.bottom;
contentPadding: const EdgeInsets.fromLTRB(16, 12, 16, 8), return MediaQuery.removeViewInsets(
title: Text(isEdit ? "顧客を編集" : "顧客を新規登録"), removeBottom: true,
content: KeyboardInsetWrapper( context: context,
basePadding: const EdgeInsets.fromLTRB(0, 0, 0, 12), child: AlertDialog(
extraBottom: 32, insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
child: SingleChildScrollView( contentPadding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
title: Text(isEdit ? "顧客を編集" : "顧客を新規登録"),
content: SingleChildScrollView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.only(bottom: 24), padding: EdgeInsets.only(bottom: inset + 12),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar( appBar: AppBar(
leading: const BackButton(), leading: const BackButton(),
title: Text(widget.selectionMode ? "C2:顧客選択" : "C1:顧客一覧"), title: Text(widget.selectionMode ? "C2:顧客選択" : "C1:顧客一覧"),
@ -712,82 +716,98 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
), ),
], ],
), ),
body: KeyboardInsetWrapper( body: Padding(
basePadding: const EdgeInsets.fromLTRB(0, 8, 0, 80), padding: const EdgeInsets.only(top: 8, bottom: 8),
extraBottom: 40, child: CustomScrollView(
child: Column( physics: const AlwaysScrollableScrollPhysics(),
children: [ keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
Padding( slivers: [
padding: const EdgeInsets.all(12), SliverToBoxAdapter(
child: TextField( child: Padding(
controller: _searchController, padding: const EdgeInsets.all(12),
decoration: InputDecoration( child: TextField(
hintText: widget.selectionMode ? "名前で検索して選択" : "名前で検索 (電話帳参照ボタンは詳細で)", controller: _searchController,
prefixIcon: const Icon(Icons.search), decoration: InputDecoration(
filled: true, hintText: widget.selectionMode ? "名前で検索して選択" : "名前で検索 (電話帳参照ボタンは詳細で)",
fillColor: Colors.white, prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
),
onChanged: (_) => setState(_applyFilter),
), ),
onChanged: (_) => setState(_applyFilter),
), ),
), ),
if (!widget.selectionMode) if (!widget.selectionMode)
Padding( SliverToBoxAdapter(
padding: const EdgeInsets.symmetric(horizontal: 12), child: Padding(
child: SwitchListTile( padding: const EdgeInsets.symmetric(horizontal: 12),
title: const Text('株式会社/有限会社などの接頭辞を無視してソート'), child: SwitchListTile(
value: _ignoreCorpPrefix, title: const Text('株式会社/有限会社などの接頭辞を無視してソート'),
onChanged: (v) => setState(() { value: _ignoreCorpPrefix,
_ignoreCorpPrefix = v; onChanged: (v) => setState(() {
_applyFilter(); _ignoreCorpPrefix = v;
}), _applyFilter();
}),
),
), ),
), ),
Expanded( if (_isLoading)
child: _isLoading const SliverFillRemaining(
? const Center(child: CircularProgressIndicator()) hasScrollBody: false,
: _filtered.isEmpty child: Center(child: CircularProgressIndicator()),
? const Center(child: Text("顧客が登録されていません")) )
: ListView.builder( else if (_filtered.isEmpty)
padding: const EdgeInsets.only(bottom: 120, top: 4), const SliverFillRemaining(
itemCount: _filtered.length, hasScrollBody: false,
itemBuilder: (context, index) { child: Center(child: Text("顧客が登録されていません")),
final c = _filtered[index]; )
return ListTile( else
leading: CircleAvatar( SliverPadding(
backgroundColor: c.isLocked ? Colors.grey.shade300 : Colors.indigo.shade100, padding: const EdgeInsets.only(bottom: 80, top: 4),
child: Stack( sliver: SliverList.builder(
children: [ itemCount: _filtered.length,
const Align(alignment: Alignment.center, child: Icon(Icons.person, color: Colors.indigo)), itemBuilder: (context, index) {
if (c.isLocked) final c = _filtered[index];
const Align(alignment: Alignment.bottomRight, child: Icon(Icons.lock, size: 14, color: Colors.redAccent)), return ListTile(
], leading: CircleAvatar(
), backgroundColor: c.isLocked ? Colors.grey.shade300 : Colors.indigo.shade100,
), child: Stack(
title: Text(c.displayName, style: TextStyle(fontWeight: FontWeight.bold, color: c.isLocked ? Colors.grey : Colors.black87)), children: [
subtitle: Text("${c.formalName} ${c.title}"), const Align(alignment: Alignment.center, child: Icon(Icons.person, color: Colors.indigo)),
onTap: widget.selectionMode ? () => Navigator.pop(context, c) : () => _showDetailPane(c), if (c.isLocked)
trailing: widget.selectionMode const Align(alignment: Alignment.bottomRight, child: Icon(Icons.lock, size: 14, color: Colors.redAccent)),
? null ],
: IconButton(
icon: const Icon(Icons.edit),
onPressed: c.isLocked ? null : () => _addOrEditCustomer(customer: c),
tooltip: c.isLocked ? "ロック中" : "編集",
),
onLongPress: () => _showContextActions(c),
);
},
), ),
), ),
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( floatingActionButton: Builder(
onPressed: _showAddMenu, builder: (context) {
icon: const Icon(Icons.add), return FloatingActionButton.extended(
label: Text(widget.selectionMode ? "選択" : "追加"), onPressed: _showAddMenu,
backgroundColor: Colors.indigo, icon: const Icon(Icons.add),
foregroundColor: Colors.white, label: Text(widget.selectionMode ? "選択" : "追加"),
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
);
},
), ),
); );
} }

View file

@ -222,84 +222,93 @@ class _CustomerPickerModalState extends State<CustomerPickerModal> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return Material(
child: KeyboardInsetWrapper( child: KeyboardInsetWrapper(
basePadding: const EdgeInsets.fromLTRB(0, 0, 0, 24), basePadding: const EdgeInsets.fromLTRB(0, 0, 0, 16),
extraBottom: 24, extraBottom: 32,
child: Column( child: CustomScrollView(
children: [ keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
Padding( slivers: [
padding: const EdgeInsets.all(16.0), SliverToBoxAdapter(
child: Column( child: Padding(
crossAxisAlignment: CrossAxisAlignment.start, padding: const EdgeInsets.all(16.0),
children: [ child: Column(
Row( crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
children: [ Row(
const Text("顧客マスター管理", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), mainAxisAlignment: MainAxisAlignment.spaceBetween,
IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)), 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)),
), ),
onChanged: _onSearch, const SizedBox(height: 12),
), TextField(
const SizedBox(height: 12), decoration: InputDecoration(
SizedBox( hintText: "登録済み顧客を検索...",
width: double.infinity, prefixIcon: const Icon(Icons.search),
child: ElevatedButton.icon( border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
onPressed: _isImportingFromContacts ? null : _importFromPhoneContacts, ),
icon: _isImportingFromContacts onChanged: _onSearch,
? 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),
], 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), const SliverToBoxAdapter(child: Divider(height: 1)),
Expanded( if (_isLoading)
child: _isLoading const SliverFillRemaining(
? const Center(child: CircularProgressIndicator()) hasScrollBody: false,
: _filteredCustomers.isEmpty child: Center(child: CircularProgressIndicator()),
? const Center(child: Text("該当する顧客がいません")) )
: ListView.builder( else if (_filteredCustomers.isEmpty)
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, const SliverFillRemaining(
padding: const EdgeInsets.only(bottom: 80), hasScrollBody: false,
itemCount: _filteredCustomers.length, child: Center(child: Text("該当する顧客がいません")),
itemBuilder: (context, index) { )
final customer = _filteredCustomers[index]; else
return ListTile( SliverPadding(
leading: const CircleAvatar(child: Icon(Icons.business)), padding: const EdgeInsets.only(bottom: 120),
title: Text(customer.formalName), sliver: SliverList.builder(
subtitle: Text(customer.department?.isNotEmpty == true ? customer.department! : "部署未設定"), itemCount: _filteredCustomers.length,
onTap: () => widget.onCustomerSelected(customer), itemBuilder: (context, index) {
trailing: Row( final customer = _filteredCustomers[index];
mainAxisSize: MainAxisSize.min, return ListTile(
children: [ leading: const CircleAvatar(child: Icon(Icons.business)),
IconButton( title: Text(customer.formalName),
icon: const Icon(Icons.edit, color: Colors.blueGrey, size: 20), subtitle: Text(customer.department?.isNotEmpty == true ? customer.department! : "部署未設定"),
onPressed: () => _showCustomerEditDialog( onTap: () => widget.onCustomerSelected(customer),
displayName: customer.displayName, trailing: Row(
initialFormalName: customer.formalName, mainAxisSize: MainAxisSize.min,
existingCustomer: customer, children: [
), IconButton(
), icon: const Icon(Icons.edit, color: Colors.blueGrey, size: 20),
IconButton( onPressed: () => _showCustomerEditDialog(
icon: const Icon(Icons.delete_outline, color: Colors.redAccent, size: 20), displayName: customer.displayName,
onPressed: () => _confirmDelete(customer), initialFormalName: customer.formalName,
), existingCustomer: customer,
], ),
), ),
); IconButton(
}, icon: const Icon(Icons.delete_outline, color: Colors.redAccent, size: 20),
), onPressed: () => _confirmDelete(customer),
), ),
],
),
);
},
),
),
], ],
), ),
), ),

View file

@ -11,7 +11,7 @@ import 'invoice_input_screen.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
import 'company_info_screen.dart'; import 'company_info_screen.dart';
import '../widgets/slide_to_unlock.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 'package:package_info_plus/package_info_plus.dart';
import '../widgets/invoice_pdf_preview_page.dart'; import '../widgets/invoice_pdf_preview_page.dart';
import 'invoice_history/invoice_history_list.dart'; import 'invoice_history/invoice_history_list.dart';
@ -375,15 +375,7 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
), ),
floatingActionButton: FloatingActionButton.extended( floatingActionButton: FloatingActionButton.extended(
onPressed: _isUnlocked onPressed: _isUnlocked
? () async { ? () => _showCreateTypeMenu()
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => InvoiceFlowScreen(onComplete: _loadData),
),
);
_loadData();
}
: _requireUnlock, : _requireUnlock,
label: const Text("新規伝票作成"), label: const Text("新規伝票作成"),
icon: const Icon(Icons.add), 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();
}
} }

View file

@ -9,17 +9,19 @@ import '../widgets/invoice_pdf_preview_page.dart';
import 'invoice_detail_page.dart'; import 'invoice_detail_page.dart';
import '../services/gps_service.dart'; import '../services/gps_service.dart';
import 'customer_master_screen.dart'; import 'customer_master_screen.dart';
import 'product_picker_modal.dart'; import 'product_master_screen.dart';
import '../widgets/keyboard_inset_wrapper.dart'; import '../models/product_model.dart';
class InvoiceInputForm extends StatefulWidget { class InvoiceInputForm extends StatefulWidget {
final Function(Invoice invoice, String filePath) onInvoiceGenerated; final Function(Invoice invoice, String filePath) onInvoiceGenerated;
final Invoice? existingInvoice; // : final Invoice? existingInvoice; // :
final DocumentType initialDocumentType;
const InvoiceInputForm({ const InvoiceInputForm({
super.key, super.key,
required this.onInvoiceGenerated, required this.onInvoiceGenerated,
this.existingInvoice, // this.existingInvoice, //
this.initialDocumentType = DocumentType.invoice,
}); });
@override @override
@ -72,21 +74,26 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
_taxRate = 0; _taxRate = 0;
_includeTax = false; _includeTax = false;
_isDraft = true; _isDraft = true;
_documentType = widget.initialDocumentType;
} }
}); });
} }
void _addItem() { void _addItem() {
showModalBottomSheet( Navigator.push<Product>(
context: context, context,
isScrollControlled: true, MaterialPageRoute(builder: (_) => const ProductMasterScreen(selectionMode: true)),
builder: (context) => ProductPickerModal( ).then((product) {
onItemSelected: (item) { if (product == null) return;
setState(() => _items.add(item)); setState(() {
Navigator.pop(context); _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)); int get _subTotal => _items.fold(0, (sum, item) => sum + (item.unitPrice * item.quantity));
@ -215,42 +222,33 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
), ),
body: Stack( body: Stack(
children: [ children: [
KeyboardInsetWrapper( Column(
basePadding: const EdgeInsets.fromLTRB(0, 0, 0, 0), children: [
extraBottom: 24, Expanded(
child: InteractiveViewer( child: SingleChildScrollView(
panEnabled: false, padding: EdgeInsets.fromLTRB(16, 16, 16, MediaQuery.of(context).viewInsets.bottom + 140),
minScale: 0.8, keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
maxScale: 2.5, child: Column(
clipBehavior: Clip.none, crossAxisAlignment: CrossAxisAlignment.start,
child: Column( children: [
children: [ _buildDateSection(),
Expanded( const SizedBox(height: 16),
child: SingleChildScrollView( _buildCustomerSection(),
padding: const EdgeInsets.fromLTRB(16, 16, 16, 160), const SizedBox(height: 16),
child: Column( _buildSubjectSection(textColor),
crossAxisAlignment: CrossAxisAlignment.start, const SizedBox(height: 20),
children: [ _buildItemsSection(fmt),
_buildDateSection(), const SizedBox(height: 20),
const SizedBox(height: 16), _buildSummarySection(fmt),
_buildCustomerSection(), const SizedBox(height: 20),
const SizedBox(height: 16), _buildSignatureSection(),
_buildSubjectSection(textColor), const SizedBox(height: 12),
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) if (_isSaving)
Container( Container(
@ -404,18 +402,12 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
TextButton.icon( TextButton.icon(
icon: const Icon(Icons.search, size: 18), icon: const Icon(Icons.search, size: 18),
label: const Text("マスター参照"), label: const Text("マスター参照"),
onPressed: () { onPressed: () async {
showModalBottomSheet( Navigator.pop(context); // close edit dialog before jumping
context: context, await Navigator.push(
isScrollControlled: true, this.context,
builder: (context) => ProductPickerModal( MaterialPageRoute(builder: (_) => const ProductMasterScreen()),
onItemSelected: (selected) { );
descCtrl.text = selected.description;
priceCtrl.text = selected.unitPrice.toString();
Navigator.pop(context); // close picker
},
),
);
}, },
), ),
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")), TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),

View file

@ -3,10 +3,11 @@ import 'package:uuid/uuid.dart';
import '../models/product_model.dart'; import '../models/product_model.dart';
import '../services/product_repository.dart'; import '../services/product_repository.dart';
import 'barcode_scanner_screen.dart'; import 'barcode_scanner_screen.dart';
import '../widgets/keyboard_inset_wrapper.dart';
class ProductMasterScreen extends StatefulWidget { class ProductMasterScreen extends StatefulWidget {
const ProductMasterScreen({super.key}); final bool selectionMode;
const ProductMasterScreen({super.key, this.selectionMode = false});
@override @override
State<ProductMasterScreen> createState() => _ProductMasterScreenState(); State<ProductMasterScreen> createState() => _ProductMasterScreenState();
@ -59,65 +60,71 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
final result = await showDialog<Product>( final result = await showDialog<Product>(
context: context, context: context,
builder: (context) => StatefulBuilder( builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog( builder: (context, setDialogState) {
title: Text(product == null ? "商品追加" : "商品編集"), final inset = MediaQuery.of(context).viewInsets.bottom;
content: KeyboardInsetWrapper( return MediaQuery.removeViewInsets(
basePadding: EdgeInsets.zero, removeBottom: true,
extraBottom: 16, context: context,
child: SingleChildScrollView( child: AlertDialog(
child: Column( insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
mainAxisSize: MainAxisSize.min, title: Text(product == null ? "商品追加" : "商品編集"),
children: [ content: SingleChildScrollView(
TextField(controller: nameController, decoration: const InputDecoration(labelText: "商品名")), keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
TextField(controller: categoryController, decoration: const InputDecoration(labelText: "カテゴリ")), padding: EdgeInsets.only(bottom: inset + 12),
TextField(controller: priceController, decoration: const InputDecoration(labelText: "初期単価"), keyboardType: TextInputType.number), child: Column(
TextField(controller: stockController, decoration: const InputDecoration(labelText: "在庫数"), keyboardType: TextInputType.number), mainAxisSize: MainAxisSize.min,
const SizedBox(height: 8), children: [
Row( TextField(controller: nameController, decoration: const InputDecoration(labelText: "商品名")),
children: [ TextField(controller: categoryController, decoration: const InputDecoration(labelText: "カテゴリ")),
Expanded( TextField(controller: priceController, decoration: const InputDecoration(labelText: "初期単価"), keyboardType: TextInputType.number),
child: TextField(controller: barcodeController, decoration: const InputDecoration(labelText: "バーコード")), TextField(controller: stockController, decoration: const InputDecoration(labelText: "在庫数"), keyboardType: TextInputType.number),
), const SizedBox(height: 8),
IconButton( Row(
icon: const Icon(Icons.qr_code_scanner), children: [
onPressed: () async { Expanded(
final code = await Navigator.push<String>( child: TextField(controller: barcodeController, decoration: const InputDecoration(labelText: "バーコード")),
context, ),
MaterialPageRoute(builder: (context) => const BarcodeScannerScreen()), IconButton(
); icon: const Icon(Icons.qr_code_scanner),
if (code != null) { onPressed: () async {
setDialogState(() => barcodeController.text = code); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar( appBar: AppBar(
leading: const BackButton(), leading: const BackButton(),
title: const Text("P1:商品マスター"), title: const Text("P1:商品マスター"),
@ -157,15 +165,15 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
), ),
), ),
), ),
body: KeyboardInsetWrapper( body: Padding(
basePadding: EdgeInsets.zero, padding: const EdgeInsets.only(top: 8, bottom: 8),
extraBottom: 72,
child: _isLoading child: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _filteredProducts.isEmpty : _filteredProducts.isEmpty
? const Center(child: Text("商品が見つかりません")) ? const Center(child: Text("商品が見つかりません"))
: ListView.builder( : ListView.builder(
padding: const EdgeInsets.only(bottom: 120, top: 8), physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 80, top: 8),
itemCount: _filteredProducts.length, itemCount: _filteredProducts.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final p = _filteredProducts[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)), title: Text(p.name, style: TextStyle(fontWeight: FontWeight.bold, color: p.isLocked ? Colors.grey : Colors.black87)),
subtitle: Text("${p.category ?? '未分類'} - ¥${p.defaultUnitPrice} (在庫: ${p.stockQuantity})"), subtitle: Text("${p.category ?? '未分類'} - ¥${p.defaultUnitPrice} (在庫: ${p.stockQuantity})"),
onTap: () => _showDetailPane(p), onTap: () {
trailing: IconButton( if (widget.selectionMode) {
icon: const Icon(Icons.edit), Navigator.pop(context, p);
onPressed: p.isLocked ? null : () => _showEditDialog(product: p), } else {
tooltip: p.isLocked ? "ロック中" : "編集", _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 ? "ロック中" : "編集",
),
); );
}, },
), ),

View file

@ -45,12 +45,15 @@ class _ProductPickerModalState extends State<ProductPickerModal> {
child: Column( child: Column(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.fromLTRB(8, 8, 16, 8),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ 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)), 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), leading: const Icon(Icons.inventory_2_outlined),
title: Text(product.name), title: Text(product.name),
subtitle: Text("${product.defaultUnitPrice} (在庫: ${product.stockQuantity})"), subtitle: Text("${product.defaultUnitPrice} (在庫: ${product.stockQuantity})"),
onTap: () => widget.onItemSelected( onTap: () {
InvoiceItem( widget.onItemSelected(
productId: product.id, InvoiceItem(
description: product.name, productId: product.id,
quantity: 1, description: product.name,
unitPrice: product.defaultUnitPrice, 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);
}
},
),
],
),
),
);
},
); );
}, },
), ),

View file

@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:convert'; import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../widgets/keyboard_inset_wrapper.dart';
import 'company_info_screen.dart'; import 'company_info_screen.dart';
class SettingsScreen extends StatefulWidget { class SettingsScreen extends StatefulWidget {
@ -227,6 +226,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
final listBottomPadding = 24 + bottomInset;
return Scaffold( return Scaffold(
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
appBar: AppBar( appBar: AppBar(
@ -238,11 +239,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
], ],
), ),
body: KeyboardInsetWrapper( body: Padding(
basePadding: const EdgeInsets.fromLTRB(16, 16, 16, 80), padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
extraBottom: 40,
child: ListView( child: ListView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.only(bottom: listBottomPadding),
children: [ children: [
_section( _section(
title: '自社情報', title: '自社情報',