diff --git a/lib/screens/customer_picker_modal.dart b/lib/screens/customer_picker_modal.dart index 72e864f..2e8c3c9 100644 --- a/lib/screens/customer_picker_modal.dart +++ b/lib/screens/customer_picker_modal.dart @@ -224,6 +224,7 @@ class _CustomerPickerModalState extends State { @override Widget build(BuildContext context) { final body = KeyboardInsetWrapper( + safeAreaTop: false, basePadding: const EdgeInsets.fromLTRB(0, 0, 0, 16), extraBottom: 32, child: CustomScrollView( @@ -307,15 +308,22 @@ class _CustomerPickerModalState extends State { ), ); - return Scaffold( - appBar: AppBar( - leading: IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(context), + return SafeArea( + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + child: Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + automaticallyImplyLeading: false, + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + title: const ScreenAppBarTitle(screenId: 'UC', title: '顧客選択'), + ), + body: body, ), - title: const ScreenAppBarTitle(screenId: 'UC', title: '顧客選択'), ), - body: SafeArea(child: body), ); } } diff --git a/lib/screens/product_picker_modal.dart b/lib/screens/product_picker_modal.dart index 7926c00..c29f63c 100644 --- a/lib/screens/product_picker_modal.dart +++ b/lib/screens/product_picker_modal.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import '../models/invoice_models.dart'; import '../models/product_model.dart'; import '../services/product_repository.dart'; +import '../widgets/keyboard_inset_wrapper.dart'; +import '../widgets/screen_id_title.dart'; import 'product_master_screen.dart'; /// 商品マスターから項目を選択するためのモーダル(スタブ実装) @@ -38,136 +40,137 @@ class _ProductPickerModalState extends State { @override Widget build(BuildContext context) { - return Container( - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(8, 8, 16, 8), - child: Row( + return SafeArea( + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + child: Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + automaticallyImplyLeading: false, + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + title: const ScreenAppBarTitle(screenId: 'P6', title: '商品・サービス選択'), + ), + body: KeyboardInsetWrapper( + safeAreaTop: false, + basePadding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + extraBottom: 24, + child: Column( children: [ - IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.pop(context), + TextField( + controller: _searchController, + autofocus: true, + decoration: InputDecoration( + hintText: "商品名・カテゴリ・バーコードで検索", + prefixIcon: const Icon(Icons.search), + suffixIcon: IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + _onSearch(""); + }, + ), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + contentPadding: const EdgeInsets.symmetric(vertical: 0), + ), + onChanged: _onSearch, + ), + const SizedBox(height: 12), + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _products.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text("商品が見つかりません"), + TextButton( + onPressed: () async { + await Navigator.push(context, MaterialPageRoute(builder: (context) => const ProductMasterScreen())); + _onSearch(_searchController.text); + }, + child: const Text("マスターに追加する"), + ), + ], + ), + ) + : ListView.builder( + itemCount: _products.length, + itemBuilder: (context, index) { + final product = _products[index]; + return ListTile( + leading: const Icon(Icons.inventory_2_outlined), + title: Text(product.name), + subtitle: Text("¥${product.defaultUnitPrice} (在庫: ${product.stockQuantity})"), + onTap: () { + widget.onProductSelected?.call(product); + 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); + } + }, + ), + ], + ), + ), + ); + }, + ); + }, + ), ), - const SizedBox(width: 4), - const Text("商品・サービス選択", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), ], ), ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: TextField( - controller: _searchController, - autofocus: true, - decoration: InputDecoration( - hintText: "商品名・カテゴリ・バーコードで検索", - prefixIcon: const Icon(Icons.search), - suffixIcon: IconButton( - icon: const Icon(Icons.clear), - onPressed: () { _searchController.clear(); _onSearch(""); }, - ), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), - contentPadding: const EdgeInsets.symmetric(vertical: 0), - ), - onChanged: _onSearch, - ), - ), - const SizedBox(height: 8), - const Divider(), - Expanded( - child: _isLoading - ? const Center(child: CircularProgressIndicator()) - : _products.isEmpty - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text("商品が見つかりません"), - TextButton( - onPressed: () async { - await Navigator.push(context, MaterialPageRoute(builder: (context) => const ProductMasterScreen())); - _onSearch(_searchController.text); - }, - child: const Text("マスターに追加する"), - ), - ], - ), - ) - : ListView.builder( - itemCount: _products.length, - itemBuilder: (context, index) { - final product = _products[index]; - return ListTile( - leading: const Icon(Icons.inventory_2_outlined), - title: Text(product.name), - subtitle: Text("¥${product.defaultUnitPrice} (在庫: ${product.stockQuantity})"), - onTap: () { - widget.onProductSelected?.call(product); - 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/purchase_entries_screen.dart b/lib/screens/purchase_entries_screen.dart index b2697cc..907db56 100644 --- a/lib/screens/purchase_entries_screen.dart +++ b/lib/screens/purchase_entries_screen.dart @@ -7,7 +7,9 @@ import 'package:uuid/uuid.dart'; import '../models/purchase_entry_models.dart'; import '../models/supplier_model.dart'; import '../services/purchase_entry_service.dart'; +import '../widgets/keyboard_inset_wrapper.dart'; import '../widgets/line_item_editor.dart'; +import '../widgets/modal_utils.dart'; import '../widgets/screen_id_title.dart'; import 'product_picker_modal.dart'; import 'supplier_picker_modal.dart'; @@ -241,21 +243,18 @@ class _PurchaseEntryEditorPageState extends State { } Future _pickSupplier() async { - await showModalBottomSheet( + final selected = await showFeatureModalBottomSheet( context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, builder: (ctx) => SupplierPickerModal( onSupplierSelected: (supplier) { Navigator.pop(ctx, supplier); }, ), - ).then((selected) { - if (selected == null) return; - setState(() { - _supplier = selected; - _supplierSnapshot = selected.name; - }); + ); + if (selected == null) return; + setState(() { + _supplier = selected; + _supplierSnapshot = selected.name; }); } @@ -285,9 +284,8 @@ class _PurchaseEntryEditorPageState extends State { } Future _pickProduct(int index) async { - await showModalBottomSheet( + await showFeatureModalBottomSheet( context: context, - isScrollControlled: true, builder: (_) => ProductPickerModal( onItemSelected: (_) {}, onProductSelected: (product) { @@ -350,6 +348,7 @@ class _PurchaseEntryEditorPageState extends State { Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.grey.shade200, + resizeToAvoidBottomInset: false, appBar: AppBar( leading: const BackButton(), title: ScreenAppBarTitle( @@ -360,71 +359,81 @@ class _PurchaseEntryEditorPageState extends State { TextButton(onPressed: _isSaving ? null : _save, child: const Text('保存')), ], ), - body: SingleChildScrollView( - padding: EdgeInsets.fromLTRB(16, 16, 16, MediaQuery.of(context).viewInsets.bottom + 32), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Card( - color: Colors.white, - child: ListTile( - title: Text(_supplierSnapshot ?? '仕入先を選択'), - subtitle: const Text('タップして仕入先を選択'), - trailing: const Icon(Icons.chevron_right), - onTap: _pickSupplier, - ), - ), - const SizedBox(height: 12), - Card( - color: Colors.white, - child: ListTile( - title: const Text('計上日'), - subtitle: Text(DateFormat('yyyy/MM/dd').format(_issueDate)), - trailing: TextButton(onPressed: _pickIssueDate, child: const Text('変更')), - ), - ), - const SizedBox(height: 12), - Card( - color: Colors.white, - child: Padding( - padding: const EdgeInsets.all(12), - child: TextField( - controller: _subjectController, - decoration: const InputDecoration(labelText: '件名'), + body: KeyboardInsetWrapper( + safeAreaTop: false, + basePadding: const EdgeInsets.fromLTRB(16, 16, 16, 16), + extraBottom: 32, + child: SingleChildScrollView( + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Card( + color: Colors.white, + child: ListTile( + title: Text(_supplierSnapshot ?? '仕入先を選択'), + subtitle: const Text('タップして仕入先を選択'), + trailing: const Icon(Icons.chevron_right), + onTap: _pickSupplier, ), ), - ), - const SizedBox(height: 20), - Text('明細', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - ..._lines.asMap().entries.map( - (entry) => Padding( - padding: const EdgeInsets.only(bottom: 8), - child: LineItemCard( - data: entry.value, - onPickProduct: () => _pickProduct(entry.key), - onRemove: () => _removeLine(entry.key), + const SizedBox(height: 12), + Card( + color: Colors.white, + child: ListTile( + title: const Text('計上日'), + subtitle: Text(DateFormat('yyyy/MM/dd').format(_issueDate)), + trailing: TextButton(onPressed: _pickIssueDate, child: const Text('変更')), ), ), - ), - Align( - alignment: Alignment.centerLeft, - child: TextButton.icon(onPressed: _addLine, icon: const Icon(Icons.add), label: const Text('明細を追加')), - ), - const SizedBox(height: 20), - Card( - color: Colors.white, - child: Padding( - padding: const EdgeInsets.all(12), - child: TextField( - controller: _notesController, - decoration: const InputDecoration(labelText: 'メモ'), - minLines: 2, - maxLines: 4, + const SizedBox(height: 12), + Card( + color: Colors.white, + child: Padding( + padding: const EdgeInsets.all(12), + child: TextField( + controller: _subjectController, + decoration: const InputDecoration(labelText: '件名'), + ), ), ), - ), - ], + const SizedBox(height: 20), + Text('明細', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + ..._lines.asMap().entries.map( + (entry) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: LineItemCard( + data: entry.value, + onPickProduct: () => _pickProduct(entry.key), + onRemove: () => _removeLine(entry.key), + ), + ), + ), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon(onPressed: _addLine, icon: const Icon(Icons.add), label: const Text('明細を追加')), + ), + const SizedBox(height: 20), + Card( + color: Colors.white, + child: Padding( + padding: const EdgeInsets.all(12), + child: KeyboardInsetWrapper( + basePadding: EdgeInsets.zero, + safeAreaTop: false, + safeAreaBottom: false, + child: TextField( + controller: _notesController, + decoration: const InputDecoration(labelText: 'メモ'), + minLines: 2, + maxLines: 4, + ), + ), + ), + ), + ], + ), ), ), ); diff --git a/lib/screens/purchase_receipts_screen.dart b/lib/screens/purchase_receipts_screen.dart index 2f6d097..015857a 100644 --- a/lib/screens/purchase_receipts_screen.dart +++ b/lib/screens/purchase_receipts_screen.dart @@ -6,6 +6,7 @@ import '../models/supplier_model.dart'; import '../services/purchase_entry_service.dart'; import '../services/purchase_receipt_service.dart'; import '../services/supplier_repository.dart'; +import '../widgets/keyboard_inset_wrapper.dart'; import '../widgets/screen_id_title.dart'; import 'supplier_picker_modal.dart'; @@ -394,10 +395,13 @@ class _PurchaseReceiptEditorPageState extends State { context: context, isScrollControlled: true, backgroundColor: Colors.transparent, - builder: (ctx) => SupplierPickerModal( - onSupplierSelected: (supplier) { - Navigator.pop(ctx, supplier); - }, + builder: (ctx) => FractionallySizedBox( + heightFactor: 0.9, + child: SupplierPickerModal( + onSupplierSelected: (supplier) { + Navigator.pop(ctx, supplier); + }, + ), ), ); if (selected == null) return; @@ -548,10 +552,11 @@ class _PurchaseReceiptEditorPageState extends State { ), body: _isInitializing ? const Center(child: CircularProgressIndicator()) - : SingleChildScrollView( - padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom + 24), - child: Padding( - padding: const EdgeInsets.all(16), + : KeyboardInsetWrapper( + safeAreaTop: false, + basePadding: const EdgeInsets.all(16), + extraBottom: 24, + child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/screens/sales_entries_screen.dart b/lib/screens/sales_entries_screen.dart index 019ce5b..21c51dc 100644 --- a/lib/screens/sales_entries_screen.dart +++ b/lib/screens/sales_entries_screen.dart @@ -12,6 +12,7 @@ import '../services/app_settings_repository.dart'; import '../services/edit_log_repository.dart'; import '../services/sales_entry_service.dart'; import '../widgets/line_item_editor.dart'; +import '../widgets/modal_utils.dart'; import '../widgets/screen_id_title.dart'; import 'customer_picker_modal.dart'; import 'settings_screen.dart'; @@ -758,10 +759,8 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> { } Future _pickCustomer() async { - final selected = await showModalBottomSheet( + final selected = await showFeatureModalBottomSheet( context: context, - isScrollControlled: true, - useSafeArea: true, builder: (ctx) => CustomerPickerModal( onCustomerSelected: (customer) { Navigator.pop(ctx, customer); diff --git a/lib/screens/sales_orders_screen.dart b/lib/screens/sales_orders_screen.dart index 9edb8e6..949cb95 100644 --- a/lib/screens/sales_orders_screen.dart +++ b/lib/screens/sales_orders_screen.dart @@ -13,6 +13,9 @@ import '../services/order_service.dart'; import '../services/receivable_service.dart'; import '../services/shipment_service.dart'; import '../services/shipping_label_service.dart'; +import '../widgets/keyboard_inset_wrapper.dart'; +import '../widgets/modal_utils.dart'; +import '../widgets/screen_id_title.dart'; import 'customer_picker_modal.dart'; class SalesOrdersScreen extends StatefulWidget { @@ -229,11 +232,17 @@ class _ShipmentEditorPageState extends State { @override Widget build(BuildContext context) { - final title = widget.shipment == null ? '出荷指示の作成' : '出荷情報を編集'; + final isCreating = widget.shipment == null; + final appBarTitle = isCreating ? '出荷指示作成' : '出荷情報編集'; + + final mediaQuery = MediaQuery.of(context); + final bottomInset = mediaQuery.viewInsets.bottom; + return Scaffold( + resizeToAvoidBottomInset: false, appBar: AppBar( leading: const BackButton(), - title: Text(title == '出荷指示の作成' ? 'S2:出荷指示作成' : 'S2:出荷情報編集'), + title: ScreenAppBarTitle(screenId: 'S2', title: appBarTitle), actions: [ TextButton( onPressed: _isSaving ? null : _save, @@ -243,80 +252,95 @@ class _ShipmentEditorPageState extends State { ), ], ), - body: SafeArea( - child: GestureDetector( - onTap: () => FocusScope.of(context).unfocus(), - child: ListView( - padding: const EdgeInsets.all(20), - children: [ - TextField( - controller: _orderIdController, - decoration: const InputDecoration(labelText: '受注ID (任意)', border: OutlineInputBorder()), - ), - const SizedBox(height: 12), - TextField( - controller: _orderNumberController, - decoration: const InputDecoration(labelText: '受注番号スナップショット', border: OutlineInputBorder()), - ), - const SizedBox(height: 12), - TextField( - controller: _customerNameController, - decoration: const InputDecoration(labelText: '顧客名スナップショット', border: OutlineInputBorder()), - ), - const SizedBox(height: 12), - Row( + body: MediaQuery( + data: mediaQuery.removeViewInsets(removeBottom: true), + child: SafeArea( + top: false, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => FocusScope.of(context).unfocus(), + child: SingleChildScrollView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + 32 + bottomInset), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: ListTile( - contentPadding: EdgeInsets.zero, - title: const Text('予定出荷日'), - subtitle: Text(_scheduledDate != null ? _dateFormat.format(_scheduledDate!) : '未設定'), - trailing: IconButton(icon: const Icon(Icons.calendar_today), onPressed: _pickScheduledDate), - ), + TextField( + controller: _orderIdController, + decoration: const InputDecoration(labelText: '受注ID (任意)', border: OutlineInputBorder()), ), - Expanded( - child: ListTile( - contentPadding: EdgeInsets.zero, - title: const Text('実績出荷日'), - subtitle: Text(_actualDate != null ? _dateFormat.format(_actualDate!) : '未設定'), - trailing: IconButton(icon: const Icon(Icons.calendar_month), onPressed: _pickActualDate), - ), + const SizedBox(height: 12), + TextField( + controller: _orderNumberController, + decoration: const InputDecoration(labelText: '受注番号スナップショット', border: OutlineInputBorder()), ), + const SizedBox(height: 12), + TextField( + controller: _customerNameController, + decoration: const InputDecoration(labelText: '顧客名スナップショット', border: OutlineInputBorder()), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('予定出荷日'), + subtitle: Text(_scheduledDate != null ? _dateFormat.format(_scheduledDate!) : '未設定'), + trailing: IconButton( + icon: const Icon(Icons.calendar_today), + onPressed: _pickScheduledDate, + ), + ), + ), + Expanded( + child: ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('実績出荷日'), + subtitle: Text(_actualDate != null ? _dateFormat.format(_actualDate!) : '未設定'), + trailing: IconButton( + icon: const Icon(Icons.calendar_month), + onPressed: _pickActualDate, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + TextField( + controller: _carrierController, + decoration: const InputDecoration(labelText: '配送業者', border: OutlineInputBorder()), + ), + const SizedBox(height: 12), + TextField( + controller: _trackingController, + decoration: const InputDecoration(labelText: '追跡番号', border: OutlineInputBorder()), + ), + const SizedBox(height: 12), + TextField( + controller: _trackingUrlController, + decoration: const InputDecoration(labelText: '追跡URL', border: OutlineInputBorder()), + ), + const SizedBox(height: 12), + TextField( + controller: _notesController, + maxLines: 3, + decoration: const InputDecoration(labelText: 'メモ', border: OutlineInputBorder()), + ), + const SizedBox(height: 24), + const Text('出荷明細', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + for (int i = 0; i < _lines.length; i++) _buildLineCard(i), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _addLine, + icon: const Icon(Icons.add), + label: const Text('明細を追加'), + ), + const SizedBox(height: 32), ], ), - const SizedBox(height: 12), - TextField( - controller: _carrierController, - decoration: const InputDecoration(labelText: '配送業者', border: OutlineInputBorder()), - ), - const SizedBox(height: 12), - TextField( - controller: _trackingController, - decoration: const InputDecoration(labelText: '追跡番号', border: OutlineInputBorder()), - ), - const SizedBox(height: 12), - TextField( - controller: _trackingUrlController, - decoration: const InputDecoration(labelText: '追跡URL', border: OutlineInputBorder()), - ), - const SizedBox(height: 12), - TextField( - controller: _notesController, - maxLines: 3, - decoration: const InputDecoration(labelText: 'メモ', border: OutlineInputBorder()), - ), - const SizedBox(height: 24), - const Text('出荷明細', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), - const SizedBox(height: 12), - for (int i = 0; i < _lines.length; i++) _buildLineCard(i), - const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: _addLine, - icon: const Icon(Icons.add), - label: const Text('明細を追加'), - ), - const SizedBox(height: 32), - ], + ), ), ), ), @@ -1027,7 +1051,7 @@ class _SalesInventoryScreenState extends State { return Scaffold( appBar: AppBar( leading: const BackButton(), - title: const Text('S4:在庫管理'), + title: const ScreenAppBarTitle(screenId: 'S4', title: '在庫管理'), actions: [ IconButton( tooltip: '更新', @@ -1274,9 +1298,9 @@ class _MovementFormDialog extends StatefulWidget { const _MovementFormDialog(); static Future<_MovementFormResult?> show(BuildContext context) { - return showDialog<_MovementFormResult>( + return showFeatureModalBottomSheet<_MovementFormResult>( context: context, - builder: (_) => const Dialog(child: _MovementFormDialog()), + builder: (_) => const _MovementFormDialog(), ); } @@ -1320,52 +1344,62 @@ class _MovementFormDialogState extends State<_MovementFormDialog> { @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('入出庫を記録', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), - const SizedBox(height: 16), - DropdownButtonFormField( - initialValue: _type, - decoration: const InputDecoration(labelText: '区分', border: OutlineInputBorder()), - onChanged: (val) => setState(() => _type = val ?? InventoryMovementType.receipt), - items: InventoryMovementType.values - .map((type) => DropdownMenuItem(value: type, child: Text(type.displayName))) - .toList(), - ), - const SizedBox(height: 12), - TextField( - controller: _quantityController, - keyboardType: TextInputType.number, - decoration: InputDecoration( - labelText: _type == InventoryMovementType.adjustment ? '数量差分 (マイナス可)' : '数量', - border: const OutlineInputBorder(), + final isAdjustment = _type == InventoryMovementType.adjustment; + return SafeArea( + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + child: Scaffold( + resizeToAvoidBottomInset: false, + backgroundColor: Colors.white, + appBar: AppBar( + automaticallyImplyLeading: false, + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), ), - ), - const SizedBox(height: 12), - TextField( - controller: _referenceController, - decoration: const InputDecoration(labelText: '参照 (任意)', border: OutlineInputBorder()), - ), - const SizedBox(height: 12), - TextField( - controller: _notesController, - maxLines: 2, - decoration: const InputDecoration(labelText: 'メモ', border: OutlineInputBorder()), - ), - const SizedBox(height: 20), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('キャンセル')), - const SizedBox(width: 12), - FilledButton(onPressed: _submit, child: const Text('登録')), + title: const ScreenAppBarTitle(screenId: 'S4', title: '入出庫を記録'), + actions: [ + TextButton(onPressed: _submit, child: const Text('登録')), ], ), - ], + body: KeyboardInsetWrapper( + safeAreaTop: false, + basePadding: const EdgeInsets.fromLTRB(20, 16, 20, 16), + extraBottom: 24, + child: ListView( + children: [ + DropdownButtonFormField( + value: _type, + decoration: const InputDecoration(labelText: '区分', border: OutlineInputBorder()), + onChanged: (val) => setState(() => _type = val ?? InventoryMovementType.receipt), + items: InventoryMovementType.values + .map((type) => DropdownMenuItem(value: type, child: Text(type.displayName))) + .toList(), + ), + const SizedBox(height: 16), + TextField( + controller: _quantityController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: isAdjustment ? '数量差分 (マイナス可)' : '数量', + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + TextField( + controller: _referenceController, + decoration: const InputDecoration(labelText: '参照 (任意)', border: OutlineInputBorder()), + ), + const SizedBox(height: 16), + TextField( + controller: _notesController, + maxLines: 3, + decoration: const InputDecoration(labelText: 'メモ', border: OutlineInputBorder()), + ), + ], + ), + ), + ), ), ); } @@ -1765,9 +1799,9 @@ class _PaymentFormDialog extends StatefulWidget { const _PaymentFormDialog(); static Future<_PaymentFormResult?> show(BuildContext context) { - return showDialog<_PaymentFormResult>( + return showFeatureModalBottomSheet<_PaymentFormResult>( context: context, - builder: (ctx) => const Dialog(child: _PaymentFormDialog()), + builder: (ctx) => const _PaymentFormDialog(), ); } @@ -1780,6 +1814,7 @@ class _PaymentFormDialogState extends State<_PaymentFormDialog> { final TextEditingController _notesController = TextEditingController(); DateTime _paymentDate = DateTime.now(); PaymentMethod _method = PaymentMethod.bankTransfer; + bool _isSubmitting = false; @override void dispose() { @@ -1801,11 +1836,13 @@ class _PaymentFormDialogState extends State<_PaymentFormDialog> { } void _submit() { + if (_isSubmitting) return; final amount = int.tryParse(_amountController.text.trim()); if (amount == null || amount <= 0) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('金額を入力してください'))); return; } + setState(() => _isSubmitting = true); Navigator.of(context).pop( _PaymentFormResult( amount: amount, @@ -1819,50 +1856,80 @@ class _PaymentFormDialogState extends State<_PaymentFormDialog> { @override Widget build(BuildContext context) { final dateLabel = DateFormat('yyyy/MM/dd').format(_paymentDate); - return Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('入金を登録', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), - const SizedBox(height: 16), - TextField( - controller: _amountController, - keyboardType: TextInputType.number, - decoration: const InputDecoration(labelText: '入金額 (円)', border: OutlineInputBorder()), - ), - const SizedBox(height: 12), - ListTile( - contentPadding: EdgeInsets.zero, - title: const Text('入金日'), - subtitle: Text(dateLabel), - trailing: IconButton(icon: const Icon(Icons.calendar_today), onPressed: _pickDate), - ), - DropdownButtonFormField( - initialValue: _method, - decoration: const InputDecoration(labelText: '入金方法', border: OutlineInputBorder()), - onChanged: (val) => setState(() => _method = val ?? PaymentMethod.bankTransfer), - items: PaymentMethod.values - .map((method) => DropdownMenuItem(value: method, child: Text(method.displayName))) - .toList(), - ), - const SizedBox(height: 12), - TextField( - controller: _notesController, - maxLines: 2, - decoration: const InputDecoration(labelText: 'メモ (任意)', border: OutlineInputBorder()), - ), - const SizedBox(height: 20), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('キャンセル')), - const SizedBox(width: 12), - FilledButton(onPressed: _submit, child: const Text('登録')), + return SafeArea( + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + child: Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + automaticallyImplyLeading: false, + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: _isSubmitting ? null : () => Navigator.pop(context), + ), + title: const ScreenAppBarTitle(screenId: 'S5', title: '入金登録'), + actions: [ + TextButton( + onPressed: _isSubmitting ? null : _submit, + child: _isSubmitting + ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('保存'), + ), ], ), - ], + body: KeyboardInsetWrapper( + safeAreaTop: false, + basePadding: const EdgeInsets.fromLTRB(20, 16, 20, 16), + extraBottom: 24, + child: ListView( + children: [ + TextField( + controller: _amountController, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.next, + decoration: const InputDecoration(labelText: '入金額 (円)', border: OutlineInputBorder()), + ), + const SizedBox(height: 16), + InputDecorator( + decoration: const InputDecoration(labelText: '入金日', border: OutlineInputBorder()), + child: InkWell( + onTap: _pickDate, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(dateLabel), + const Icon(Icons.calendar_today, size: 18), + ], + ), + ), + ), + ), + const SizedBox(height: 16), + DropdownButtonFormField( + value: _method, + decoration: const InputDecoration(labelText: '入金方法', border: OutlineInputBorder()), + onChanged: (val) => setState(() => _method = val ?? PaymentMethod.bankTransfer), + items: PaymentMethod.values + .map((method) => DropdownMenuItem(value: method, child: Text(method.displayName))) + .toList(), + ), + const SizedBox(height: 16), + TextField( + controller: _notesController, + maxLines: 3, + decoration: const InputDecoration(labelText: 'メモ (任意)', border: OutlineInputBorder()), + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _isSubmitting ? null : _submit, + child: const Text('入金を登録'), + ), + ], + ), + ), + ), ), ); } @@ -1925,9 +1992,8 @@ class _SalesOrderEditorPageState extends State { } Future _pickCustomer() async { - final selected = await showModalBottomSheet( + final selected = await showFeatureModalBottomSheet( context: context, - isScrollControlled: true, builder: (ctx) => CustomerPickerModal( onCustomerSelected: (customer) { Navigator.pop(ctx, customer); @@ -2022,10 +2088,16 @@ class _SalesOrderEditorPageState extends State { @override Widget build(BuildContext context) { final title = widget.order == null ? '受注の新規登録' : '受注を編集'; + final screenTitle = widget.order == null ? '受注登録' : '受注編集'; + final mediaQuery = MediaQuery.of(context); + final bottomInset = mediaQuery.viewInsets.bottom; + return Scaffold( + backgroundColor: Colors.grey.shade200, + resizeToAvoidBottomInset: false, appBar: AppBar( leading: const BackButton(), - title: Text(title == '受注の新規登録' ? 'S6:受注登録' : 'S6:受注編集'), + title: ScreenAppBarTitle(screenId: 'S6', title: screenTitle), actions: [ TextButton( onPressed: _isSaving ? null : _save, @@ -2035,55 +2107,64 @@ class _SalesOrderEditorPageState extends State { ), ], ), - body: SafeArea( - child: GestureDetector( - onTap: () => FocusScope.of(context).unfocus(), - child: ListView( - padding: const EdgeInsets.all(20), - children: [ - ListTile( - contentPadding: EdgeInsets.zero, - title: const Text('取引先'), - subtitle: Text(_customerName ?? '未選択'), - trailing: OutlinedButton.icon( - onPressed: _pickCustomer, - icon: const Icon(Icons.search), - label: Text(_customerName == null ? '選択' : '変更'), - ), + body: MediaQuery( + data: mediaQuery.removeViewInsets(removeBottom: true), + child: SafeArea( + top: false, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => FocusScope.of(context).unfocus(), + child: SingleChildScrollView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + 32 + bottomInset), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('取引先'), + subtitle: Text(_customerName ?? '未選択'), + trailing: OutlinedButton.icon( + onPressed: _pickCustomer, + icon: const Icon(Icons.search), + label: Text(_customerName == null ? '選択' : '変更'), + ), + ), + const SizedBox(height: 12), + ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('希望出荷日'), + subtitle: Text(_requestedShipDate != null ? _dateFormat.format(_requestedShipDate!) : '未設定'), + trailing: IconButton( + icon: const Icon(Icons.calendar_today), + onPressed: _pickDate, + ), + ), + const SizedBox(height: 12), + TextField( + controller: _assigneeController, + decoration: const InputDecoration(labelText: '担当者 (任意)', border: OutlineInputBorder()), + ), + const SizedBox(height: 12), + TextField( + controller: _notesController, + maxLines: 3, + decoration: const InputDecoration(labelText: 'メモ / 特記事項', border: OutlineInputBorder()), + ), + const SizedBox(height: 24), + const Text('受注明細', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + for (int i = 0; i < _lines.length; i++) _buildLineCard(i), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _addLine, + icon: const Icon(Icons.add), + label: const Text('明細を追加'), + ), + const SizedBox(height: 32), + ], ), - const SizedBox(height: 12), - ListTile( - contentPadding: EdgeInsets.zero, - title: const Text('希望出荷日'), - subtitle: Text(_requestedShipDate != null ? _dateFormat.format(_requestedShipDate!) : '未設定'), - trailing: IconButton( - icon: const Icon(Icons.calendar_today), - onPressed: _pickDate, - ), - ), - const SizedBox(height: 12), - TextField( - controller: _assigneeController, - decoration: const InputDecoration(labelText: '担当者 (任意)', border: OutlineInputBorder()), - ), - const SizedBox(height: 12), - TextField( - controller: _notesController, - maxLines: 3, - decoration: const InputDecoration(labelText: 'メモ / 特記事項', border: OutlineInputBorder()), - ), - const SizedBox(height: 24), - const Text('受注明細', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), - const SizedBox(height: 12), - for (int i = 0; i < _lines.length; i++) _buildLineCard(i), - const SizedBox(height: 12), - OutlinedButton.icon( - onPressed: _addLine, - icon: const Icon(Icons.add), - label: const Text('明細を追加'), - ), - const SizedBox(height: 32), - ], + ), ), ), ), diff --git a/lib/screens/supplier_picker_modal.dart b/lib/screens/supplier_picker_modal.dart index e9f4733..00964f4 100644 --- a/lib/screens/supplier_picker_modal.dart +++ b/lib/screens/supplier_picker_modal.dart @@ -4,6 +4,8 @@ import 'package:uuid/uuid.dart'; import '../models/supplier_model.dart'; import '../services/supplier_repository.dart'; import '../widgets/keyboard_inset_wrapper.dart'; +import '../widgets/modal_utils.dart'; +import '../widgets/screen_id_title.dart'; class SupplierPickerModal extends StatefulWidget { const SupplierPickerModal({super.key, required this.onSupplierSelected}); @@ -42,9 +44,12 @@ class _SupplierPickerModalState extends State { } Future _openEditor({Supplier? supplier}) async { - final result = await showDialog( + final result = await showFeatureModalBottomSheet( context: context, - builder: (ctx) => _SupplierFormDialog(supplier: supplier, onSubmit: (data) => Navigator.of(ctx).pop(data)), + builder: (ctx) => _SupplierFormSheet( + supplier: supplier, + onSubmit: (data) => Navigator.of(ctx).pop(data), + ), ); if (result == null) return; final saving = result.copyWith(id: result.id.isEmpty ? _uuid.v4() : result.id, updatedAt: DateTime.now()); @@ -54,8 +59,6 @@ class _SupplierPickerModalState extends State { await _loadSuppliers(_searchController.text); if (!mounted) return; widget.onSupplierSelected(saving); - if (!mounted) return; - Navigator.pop(context); } Future _deleteSupplier(Supplier supplier) async { @@ -79,107 +82,123 @@ class _SupplierPickerModalState extends State { @override Widget build(BuildContext context) { + final theme = Theme.of(context); return SafeArea( - child: Container( - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + child: Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + automaticallyImplyLeading: false, + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + title: const ScreenAppBarTitle(screenId: 'P5', title: '仕入先選択'), + actions: [ + IconButton( + tooltip: '仕入先を追加', + onPressed: _openEditor, + icon: const Icon(Icons.add_circle_outline), + ), + ], + ), + body: KeyboardInsetWrapper( + safeAreaTop: false, + basePadding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + extraBottom: 24, + child: Column( children: [ - IconButton(onPressed: () => Navigator.pop(context), icon: const Icon(Icons.close)), - const SizedBox(width: 8), - const Text('仕入先を選択', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), - const Spacer(), - IconButton(onPressed: () => _openEditor(), icon: const Icon(Icons.add_circle_outline)), + TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: '仕入先名で検索', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchController.text.isEmpty + ? null + : IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + _loadSuppliers(''); + }, + ), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + isDense: true, + ), + onChanged: _loadSuppliers, + ), + const SizedBox(height: 12), + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _suppliers.isEmpty + ? Center( + child: Text( + '仕入先が見つかりません。右上の + から追加できます。', + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ) + : ListView.builder( + itemCount: _suppliers.length, + itemBuilder: (context, index) { + final supplier = _suppliers[index]; + return Card( + child: ListTile( + title: Text(supplier.name, style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (supplier.contactPerson?.isNotEmpty == true) Text('担当: ${supplier.contactPerson}'), + if (supplier.tel?.isNotEmpty == true) Text('TEL: ${supplier.tel}'), + ], + ), + onTap: () { + widget.onSupplierSelected(supplier); + Navigator.pop(context); + }, + trailing: PopupMenuButton( + onSelected: (value) { + switch (value) { + case 'edit': + _openEditor(supplier: supplier); + break; + case 'delete': + _deleteSupplier(supplier); + break; + } + }, + itemBuilder: (context) => const [ + PopupMenuItem(value: 'edit', child: Text('編集')), + PopupMenuItem(value: 'delete', child: Text('削除')), + ], + ), + ), + ); + }, + ), + ), ], ), - TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: '仕入先名で検索', - prefixIcon: const Icon(Icons.search), - suffixIcon: _searchController.text.isEmpty - ? null - : IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - _searchController.clear(); - _loadSuppliers(''); - }, - ), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), - isDense: true, - ), - onChanged: _loadSuppliers, - ), - const SizedBox(height: 12), - Expanded( - child: _isLoading - ? const Center(child: CircularProgressIndicator()) - : _suppliers.isEmpty - ? const Center(child: Text('仕入先が見つかりません。右上の + から追加できます。')) - : ListView.builder( - itemCount: _suppliers.length, - itemBuilder: (context, index) { - final supplier = _suppliers[index]; - return Card( - child: ListTile( - title: Text(supplier.name, style: const TextStyle(fontWeight: FontWeight.bold)), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (supplier.contactPerson?.isNotEmpty == true) Text('担当: ${supplier.contactPerson}'), - if (supplier.tel?.isNotEmpty == true) Text('TEL: ${supplier.tel}'), - ], - ), - onTap: () { - widget.onSupplierSelected(supplier); - Navigator.pop(context); - }, - trailing: PopupMenuButton( - onSelected: (value) { - switch (value) { - case 'edit': - _openEditor(supplier: supplier); - break; - case 'delete': - _deleteSupplier(supplier); - break; - } - }, - itemBuilder: (context) => const [ - PopupMenuItem(value: 'edit', child: Text('編集')), - PopupMenuItem(value: 'delete', child: Text('削除')), - ], - ), - ), - ); - }, - ), - ), - ], + ), ), ), ); } } -class _SupplierFormDialog extends StatefulWidget { - const _SupplierFormDialog({required this.onSubmit, this.supplier}); +class _SupplierFormSheet extends StatefulWidget { + const _SupplierFormSheet({required this.onSubmit, this.supplier}); final Supplier? supplier; final ValueChanged onSubmit; @override - State<_SupplierFormDialog> createState() => _SupplierFormDialogState(); + State<_SupplierFormSheet> createState() => _SupplierFormSheetState(); } -class _SupplierFormDialogState extends State<_SupplierFormDialog> { +class _SupplierFormSheetState extends State<_SupplierFormSheet> { late final TextEditingController _nameController; late final TextEditingController _contactController; late final TextEditingController _telController; @@ -187,6 +206,7 @@ class _SupplierFormDialogState extends State<_SupplierFormDialog> { late final TextEditingController _notesController; final _formKey = GlobalKey(); + bool _isSaving = false; @override void initState() { @@ -199,57 +219,91 @@ class _SupplierFormDialogState extends State<_SupplierFormDialog> { _notesController = TextEditingController(text: supplier?.notes ?? ''); } + @override + void dispose() { + _nameController.dispose(); + _contactController.dispose(); + _telController.dispose(); + _emailController.dispose(); + _notesController.dispose(); + super.dispose(); + } + + void _handleSubmit() { + if (_isSaving) return; + if (!_formKey.currentState!.validate()) return; + setState(() => _isSaving = true); + widget.onSubmit( + Supplier( + id: widget.supplier?.id ?? '', + name: _nameController.text.trim(), + contactPerson: _contactController.text.trim().isEmpty ? null : _contactController.text.trim(), + tel: _telController.text.trim().isEmpty ? null : _telController.text.trim(), + email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(), + notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(), + updatedAt: DateTime.now(), + ), + ); + } + @override Widget build(BuildContext context) { - return AlertDialog( - title: Text(widget.supplier == null ? '仕入先を追加' : '仕入先を編集'), - content: KeyboardInsetWrapper( - basePadding: const EdgeInsets.only(bottom: 8), - extraBottom: 24, - child: Form( - key: _formKey, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextFormField( - controller: _nameController, - decoration: const InputDecoration(labelText: '仕入先名 *'), - validator: (value) => value == null || value.trim().isEmpty ? '必須項目です' : null, + final title = widget.supplier == null ? '仕入先を追加' : '仕入先を編集'; + return SafeArea( + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + child: Material( + color: Colors.white, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), + child: Row( + children: [ + IconButton(onPressed: () => Navigator.pop(context), icon: const Icon(Icons.close)), + const SizedBox(width: 4), + Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const Spacer(), + TextButton( + onPressed: _isSaving ? null : _handleSubmit, + child: _isSaving + ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('保存'), + ), + ], ), - const SizedBox(height: 12), - TextFormField(controller: _contactController, decoration: const InputDecoration(labelText: '担当者')), - const SizedBox(height: 12), - TextFormField(controller: _telController, decoration: const InputDecoration(labelText: '電話番号')), - const SizedBox(height: 12), - TextFormField(controller: _emailController, decoration: const InputDecoration(labelText: 'メール')), - const SizedBox(height: 12), - TextFormField(controller: _notesController, decoration: const InputDecoration(labelText: '備考'), maxLines: 3), - ], - ), + ), + Expanded( + child: KeyboardInsetWrapper( + safeAreaTop: false, + basePadding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + extraBottom: 24, + child: Form( + key: _formKey, + child: ListView( + children: [ + TextFormField( + controller: _nameController, + decoration: const InputDecoration(labelText: '仕入先名 *'), + validator: (value) => value == null || value.trim().isEmpty ? '必須項目です' : null, + ), + const SizedBox(height: 12), + TextFormField(controller: _contactController, decoration: const InputDecoration(labelText: '担当者')), + const SizedBox(height: 12), + TextFormField(controller: _telController, decoration: const InputDecoration(labelText: '電話番号')), + const SizedBox(height: 12), + TextFormField(controller: _emailController, decoration: const InputDecoration(labelText: 'メール')), + const SizedBox(height: 12), + TextFormField(controller: _notesController, decoration: const InputDecoration(labelText: '備考'), maxLines: 3), + ], + ), + ), + ), + ), + ], ), ), ), - actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')), - FilledButton( - onPressed: () { - if (!_formKey.currentState!.validate()) return; - widget.onSubmit( - Supplier( - id: widget.supplier?.id ?? '', - name: _nameController.text.trim(), - contactPerson: _contactController.text.trim().isEmpty ? null : _contactController.text.trim(), - tel: _telController.text.trim().isEmpty ? null : _telController.text.trim(), - email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(), - notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(), - updatedAt: DateTime.now(), - ), - ); - }, - child: const Text('保存'), - ), - ], ); } } diff --git a/lib/widgets/keyboard_inset_wrapper.dart b/lib/widgets/keyboard_inset_wrapper.dart index d05de7b..8adfe2b 100644 --- a/lib/widgets/keyboard_inset_wrapper.dart +++ b/lib/widgets/keyboard_inset_wrapper.dart @@ -8,6 +8,8 @@ class KeyboardInsetWrapper extends StatelessWidget { final double extraBottom; final Duration duration; final Curve curve; + final bool safeAreaTop; + final bool safeAreaBottom; const KeyboardInsetWrapper({ super.key, @@ -16,6 +18,8 @@ class KeyboardInsetWrapper extends StatelessWidget { this.extraBottom = 0, this.duration = const Duration(milliseconds: 180), this.curve = Curves.easeOut, + this.safeAreaTop = true, + this.safeAreaBottom = true, }); @override @@ -23,15 +27,15 @@ class KeyboardInsetWrapper extends StatelessWidget { final mediaQuery = MediaQuery.of(context); final bottomInset = mediaQuery.viewInsets.bottom; final padding = basePadding.add(EdgeInsets.only(bottom: bottomInset + extraBottom)); - return MediaQuery( - data: mediaQuery.removeViewInsets(removeBottom: true), - child: SafeArea( - child: AnimatedPadding( - duration: duration, - curve: curve, - padding: padding, - child: child, - ), + final applyBottomSafeArea = safeAreaBottom && bottomInset == 0; + return SafeArea( + top: safeAreaTop, + bottom: applyBottomSafeArea, + child: AnimatedPadding( + duration: duration, + curve: curve, + padding: padding, + child: child, ), ); } diff --git a/lib/widgets/modal_utils.dart b/lib/widgets/modal_utils.dart new file mode 100644 index 0000000..81e90ff --- /dev/null +++ b/lib/widgets/modal_utils.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +/// Presents a rounded feature modal with consistent safe-area handling. +Future showFeatureModalBottomSheet({ + required BuildContext context, + required WidgetBuilder builder, + double heightFactor = 0.9, + bool isScrollControlled = true, + Color backgroundColor = Colors.transparent, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: isScrollControlled, + backgroundColor: backgroundColor, + builder: (sheetContext) => FractionallySizedBox( + heightFactor: heightFactor, + child: builder(sheetContext), + ), + ); +}