売上管理関連スクリーン画面の修正

This commit is contained in:
joe 2026-03-05 23:03:44 +09:00
parent c98dd3cc72
commit a2f7013984
9 changed files with 750 additions and 567 deletions

View file

@ -224,6 +224,7 @@ class _CustomerPickerModalState extends State<CustomerPickerModal> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final body = KeyboardInsetWrapper( final body = KeyboardInsetWrapper(
safeAreaTop: false,
basePadding: const EdgeInsets.fromLTRB(0, 0, 0, 16), basePadding: const EdgeInsets.fromLTRB(0, 0, 0, 16),
extraBottom: 32, extraBottom: 32,
child: CustomScrollView( child: CustomScrollView(
@ -307,15 +308,22 @@ class _CustomerPickerModalState extends State<CustomerPickerModal> {
), ),
); );
return Scaffold( return SafeArea(
appBar: AppBar( child: ClipRRect(
leading: IconButton( borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
icon: const Icon(Icons.close), child: Scaffold(
onPressed: () => Navigator.pop(context), 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),
); );
} }
} }

View file

@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import '../models/invoice_models.dart'; import '../models/invoice_models.dart';
import '../models/product_model.dart'; import '../models/product_model.dart';
import '../services/product_repository.dart'; import '../services/product_repository.dart';
import '../widgets/keyboard_inset_wrapper.dart';
import '../widgets/screen_id_title.dart';
import 'product_master_screen.dart'; import 'product_master_screen.dart';
/// ///
@ -38,136 +40,137 @@ class _ProductPickerModalState extends State<ProductPickerModal> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return SafeArea(
decoration: const BoxDecoration( child: ClipRRect(
color: Colors.white, borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
borderRadius: BorderRadius.vertical(top: Radius.circular(20)), child: Scaffold(
), backgroundColor: Colors.white,
child: Column( appBar: AppBar(
children: [ automaticallyImplyLeading: false,
Padding( leading: IconButton(
padding: const EdgeInsets.fromLTRB(8, 8, 16, 8), icon: const Icon(Icons.close),
child: Row( 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: [ children: [
IconButton( TextField(
icon: const Icon(Icons.arrow_back), controller: _searchController,
onPressed: () => Navigator.pop(context), 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<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);
}
},
),
],
),
),
);
},
);
},
),
), ),
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<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

@ -7,7 +7,9 @@ import 'package:uuid/uuid.dart';
import '../models/purchase_entry_models.dart'; import '../models/purchase_entry_models.dart';
import '../models/supplier_model.dart'; import '../models/supplier_model.dart';
import '../services/purchase_entry_service.dart'; import '../services/purchase_entry_service.dart';
import '../widgets/keyboard_inset_wrapper.dart';
import '../widgets/line_item_editor.dart'; import '../widgets/line_item_editor.dart';
import '../widgets/modal_utils.dart';
import '../widgets/screen_id_title.dart'; import '../widgets/screen_id_title.dart';
import 'product_picker_modal.dart'; import 'product_picker_modal.dart';
import 'supplier_picker_modal.dart'; import 'supplier_picker_modal.dart';
@ -241,21 +243,18 @@ class _PurchaseEntryEditorPageState extends State<PurchaseEntryEditorPage> {
} }
Future<void> _pickSupplier() async { Future<void> _pickSupplier() async {
await showModalBottomSheet<Supplier>( final selected = await showFeatureModalBottomSheet<Supplier>(
context: context, context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (ctx) => SupplierPickerModal( builder: (ctx) => SupplierPickerModal(
onSupplierSelected: (supplier) { onSupplierSelected: (supplier) {
Navigator.pop(ctx, supplier); Navigator.pop(ctx, supplier);
}, },
), ),
).then((selected) { );
if (selected == null) return; if (selected == null) return;
setState(() { setState(() {
_supplier = selected; _supplier = selected;
_supplierSnapshot = selected.name; _supplierSnapshot = selected.name;
});
}); });
} }
@ -285,9 +284,8 @@ class _PurchaseEntryEditorPageState extends State<PurchaseEntryEditorPage> {
} }
Future<void> _pickProduct(int index) async { Future<void> _pickProduct(int index) async {
await showModalBottomSheet<void>( await showFeatureModalBottomSheet<void>(
context: context, context: context,
isScrollControlled: true,
builder: (_) => ProductPickerModal( builder: (_) => ProductPickerModal(
onItemSelected: (_) {}, onItemSelected: (_) {},
onProductSelected: (product) { onProductSelected: (product) {
@ -350,6 +348,7 @@ class _PurchaseEntryEditorPageState extends State<PurchaseEntryEditorPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: Colors.grey.shade200, backgroundColor: Colors.grey.shade200,
resizeToAvoidBottomInset: false,
appBar: AppBar( appBar: AppBar(
leading: const BackButton(), leading: const BackButton(),
title: ScreenAppBarTitle( title: ScreenAppBarTitle(
@ -360,71 +359,81 @@ class _PurchaseEntryEditorPageState extends State<PurchaseEntryEditorPage> {
TextButton(onPressed: _isSaving ? null : _save, child: const Text('保存')), TextButton(onPressed: _isSaving ? null : _save, child: const Text('保存')),
], ],
), ),
body: SingleChildScrollView( body: KeyboardInsetWrapper(
padding: EdgeInsets.fromLTRB(16, 16, 16, MediaQuery.of(context).viewInsets.bottom + 32), safeAreaTop: false,
child: Column( basePadding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
crossAxisAlignment: CrossAxisAlignment.start, extraBottom: 32,
children: [ child: SingleChildScrollView(
Card( padding: EdgeInsets.zero,
color: Colors.white, child: Column(
child: ListTile( crossAxisAlignment: CrossAxisAlignment.start,
title: Text(_supplierSnapshot ?? '仕入先を選択'), children: [
subtitle: const Text('タップして仕入先を選択'), Card(
trailing: const Icon(Icons.chevron_right), color: Colors.white,
onTap: _pickSupplier, child: ListTile(
), title: Text(_supplierSnapshot ?? '仕入先を選択'),
), subtitle: const Text('タップして仕入先を選択'),
const SizedBox(height: 12), trailing: const Icon(Icons.chevron_right),
Card( onTap: _pickSupplier,
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: '件名'),
), ),
), ),
), const SizedBox(height: 12),
const SizedBox(height: 20), Card(
Text('明細', style: Theme.of(context).textTheme.titleMedium), color: Colors.white,
const SizedBox(height: 8), child: ListTile(
..._lines.asMap().entries.map( title: const Text('計上日'),
(entry) => Padding( subtitle: Text(DateFormat('yyyy/MM/dd').format(_issueDate)),
padding: const EdgeInsets.only(bottom: 8), trailing: TextButton(onPressed: _pickIssueDate, child: const Text('変更')),
child: LineItemCard(
data: entry.value,
onPickProduct: () => _pickProduct(entry.key),
onRemove: () => _removeLine(entry.key),
), ),
), ),
), const SizedBox(height: 12),
Align( Card(
alignment: Alignment.centerLeft, color: Colors.white,
child: TextButton.icon(onPressed: _addLine, icon: const Icon(Icons.add), label: const Text('明細を追加')), child: Padding(
), padding: const EdgeInsets.all(12),
const SizedBox(height: 20), child: TextField(
Card( controller: _subjectController,
color: Colors.white, decoration: const InputDecoration(labelText: '件名'),
child: Padding( ),
padding: const EdgeInsets.all(12),
child: TextField(
controller: _notesController,
decoration: const InputDecoration(labelText: 'メモ'),
minLines: 2,
maxLines: 4,
), ),
), ),
), 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,
),
),
),
),
],
),
), ),
), ),
); );

View file

@ -6,6 +6,7 @@ import '../models/supplier_model.dart';
import '../services/purchase_entry_service.dart'; import '../services/purchase_entry_service.dart';
import '../services/purchase_receipt_service.dart'; import '../services/purchase_receipt_service.dart';
import '../services/supplier_repository.dart'; import '../services/supplier_repository.dart';
import '../widgets/keyboard_inset_wrapper.dart';
import '../widgets/screen_id_title.dart'; import '../widgets/screen_id_title.dart';
import 'supplier_picker_modal.dart'; import 'supplier_picker_modal.dart';
@ -394,10 +395,13 @@ class _PurchaseReceiptEditorPageState extends State<PurchaseReceiptEditorPage> {
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
builder: (ctx) => SupplierPickerModal( builder: (ctx) => FractionallySizedBox(
onSupplierSelected: (supplier) { heightFactor: 0.9,
Navigator.pop(ctx, supplier); child: SupplierPickerModal(
}, onSupplierSelected: (supplier) {
Navigator.pop(ctx, supplier);
},
),
), ),
); );
if (selected == null) return; if (selected == null) return;
@ -548,10 +552,11 @@ class _PurchaseReceiptEditorPageState extends State<PurchaseReceiptEditorPage> {
), ),
body: _isInitializing body: _isInitializing
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: SingleChildScrollView( : KeyboardInsetWrapper(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom + 24), safeAreaTop: false,
child: Padding( basePadding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16), extraBottom: 24,
child: SingleChildScrollView(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [

View file

@ -12,6 +12,7 @@ import '../services/app_settings_repository.dart';
import '../services/edit_log_repository.dart'; import '../services/edit_log_repository.dart';
import '../services/sales_entry_service.dart'; import '../services/sales_entry_service.dart';
import '../widgets/line_item_editor.dart'; import '../widgets/line_item_editor.dart';
import '../widgets/modal_utils.dart';
import '../widgets/screen_id_title.dart'; import '../widgets/screen_id_title.dart';
import 'customer_picker_modal.dart'; import 'customer_picker_modal.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
@ -758,10 +759,8 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> {
} }
Future<void> _pickCustomer() async { Future<void> _pickCustomer() async {
final selected = await showModalBottomSheet<Customer?>( final selected = await showFeatureModalBottomSheet<Customer?>(
context: context, context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (ctx) => CustomerPickerModal( builder: (ctx) => CustomerPickerModal(
onCustomerSelected: (customer) { onCustomerSelected: (customer) {
Navigator.pop(ctx, customer); Navigator.pop(ctx, customer);

View file

@ -13,6 +13,9 @@ import '../services/order_service.dart';
import '../services/receivable_service.dart'; import '../services/receivable_service.dart';
import '../services/shipment_service.dart'; import '../services/shipment_service.dart';
import '../services/shipping_label_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'; import 'customer_picker_modal.dart';
class SalesOrdersScreen extends StatefulWidget { class SalesOrdersScreen extends StatefulWidget {
@ -229,11 +232,17 @@ class _ShipmentEditorPageState extends State<ShipmentEditorPage> {
@override @override
Widget build(BuildContext context) { 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( return Scaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar( appBar: AppBar(
leading: const BackButton(), leading: const BackButton(),
title: Text(title == '出荷指示の作成' ? 'S2:出荷指示作成' : 'S2:出荷情報編集'), title: ScreenAppBarTitle(screenId: 'S2', title: appBarTitle),
actions: [ actions: [
TextButton( TextButton(
onPressed: _isSaving ? null : _save, onPressed: _isSaving ? null : _save,
@ -243,80 +252,95 @@ class _ShipmentEditorPageState extends State<ShipmentEditorPage> {
), ),
], ],
), ),
body: SafeArea( body: MediaQuery(
child: GestureDetector( data: mediaQuery.removeViewInsets(removeBottom: true),
onTap: () => FocusScope.of(context).unfocus(), child: SafeArea(
child: ListView( top: false,
padding: const EdgeInsets.all(20), child: GestureDetector(
children: [ behavior: HitTestBehavior.opaque,
TextField( onTap: () => FocusScope.of(context).unfocus(),
controller: _orderIdController, child: SingleChildScrollView(
decoration: const InputDecoration(labelText: '受注ID (任意)', border: OutlineInputBorder()), keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
), padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + 32 + bottomInset),
const SizedBox(height: 12), child: Column(
TextField( crossAxisAlignment: CrossAxisAlignment.start,
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: [ children: [
Expanded( TextField(
child: ListTile( controller: _orderIdController,
contentPadding: EdgeInsets.zero, decoration: const InputDecoration(labelText: '受注ID (任意)', border: OutlineInputBorder()),
title: const Text('予定出荷日'),
subtitle: Text(_scheduledDate != null ? _dateFormat.format(_scheduledDate!) : '未設定'),
trailing: IconButton(icon: const Icon(Icons.calendar_today), onPressed: _pickScheduledDate),
),
), ),
Expanded( const SizedBox(height: 12),
child: ListTile( TextField(
contentPadding: EdgeInsets.zero, controller: _orderNumberController,
title: const Text('実績出荷日'), decoration: const InputDecoration(labelText: '受注番号スナップショット', border: OutlineInputBorder()),
subtitle: Text(_actualDate != null ? _dateFormat.format(_actualDate!) : '未設定'),
trailing: IconButton(icon: const Icon(Icons.calendar_month), onPressed: _pickActualDate),
),
), ),
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<SalesInventoryScreen> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
leading: const BackButton(), leading: const BackButton(),
title: const Text('S4:在庫管理'), title: const ScreenAppBarTitle(screenId: 'S4', title: '在庫管理'),
actions: [ actions: [
IconButton( IconButton(
tooltip: '更新', tooltip: '更新',
@ -1274,9 +1298,9 @@ class _MovementFormDialog extends StatefulWidget {
const _MovementFormDialog(); const _MovementFormDialog();
static Future<_MovementFormResult?> show(BuildContext context) { static Future<_MovementFormResult?> show(BuildContext context) {
return showDialog<_MovementFormResult>( return showFeatureModalBottomSheet<_MovementFormResult>(
context: context, context: context,
builder: (_) => const Dialog(child: _MovementFormDialog()), builder: (_) => const _MovementFormDialog(),
); );
} }
@ -1320,52 +1344,62 @@ class _MovementFormDialogState extends State<_MovementFormDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( final isAdjustment = _type == InventoryMovementType.adjustment;
padding: const EdgeInsets.all(16), return SafeArea(
child: Column( child: ClipRRect(
mainAxisSize: MainAxisSize.min, borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
crossAxisAlignment: CrossAxisAlignment.start, child: Scaffold(
children: [ resizeToAvoidBottomInset: false,
const Text('入出庫を記録', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), backgroundColor: Colors.white,
const SizedBox(height: 16), appBar: AppBar(
DropdownButtonFormField<InventoryMovementType>( automaticallyImplyLeading: false,
initialValue: _type, leading: IconButton(
decoration: const InputDecoration(labelText: '区分', border: OutlineInputBorder()), icon: const Icon(Icons.close),
onChanged: (val) => setState(() => _type = val ?? InventoryMovementType.receipt), onPressed: () => Navigator.pop(context),
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(),
), ),
), title: const ScreenAppBarTitle(screenId: 'S4', title: '入出庫を記録'),
const SizedBox(height: 12), actions: [
TextField( TextButton(onPressed: _submit, child: const Text('登録')),
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('登録')),
], ],
), ),
], body: KeyboardInsetWrapper(
safeAreaTop: false,
basePadding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
extraBottom: 24,
child: ListView(
children: [
DropdownButtonFormField<InventoryMovementType>(
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(); const _PaymentFormDialog();
static Future<_PaymentFormResult?> show(BuildContext context) { static Future<_PaymentFormResult?> show(BuildContext context) {
return showDialog<_PaymentFormResult>( return showFeatureModalBottomSheet<_PaymentFormResult>(
context: context, 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(); final TextEditingController _notesController = TextEditingController();
DateTime _paymentDate = DateTime.now(); DateTime _paymentDate = DateTime.now();
PaymentMethod _method = PaymentMethod.bankTransfer; PaymentMethod _method = PaymentMethod.bankTransfer;
bool _isSubmitting = false;
@override @override
void dispose() { void dispose() {
@ -1801,11 +1836,13 @@ class _PaymentFormDialogState extends State<_PaymentFormDialog> {
} }
void _submit() { void _submit() {
if (_isSubmitting) return;
final amount = int.tryParse(_amountController.text.trim()); final amount = int.tryParse(_amountController.text.trim());
if (amount == null || amount <= 0) { if (amount == null || amount <= 0) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('金額を入力してください'))); ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('金額を入力してください')));
return; return;
} }
setState(() => _isSubmitting = true);
Navigator.of(context).pop( Navigator.of(context).pop(
_PaymentFormResult( _PaymentFormResult(
amount: amount, amount: amount,
@ -1819,50 +1856,80 @@ class _PaymentFormDialogState extends State<_PaymentFormDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final dateLabel = DateFormat('yyyy/MM/dd').format(_paymentDate); final dateLabel = DateFormat('yyyy/MM/dd').format(_paymentDate);
return Padding( return SafeArea(
padding: const EdgeInsets.all(16), child: ClipRRect(
child: Column( borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
mainAxisSize: MainAxisSize.min, child: Scaffold(
crossAxisAlignment: CrossAxisAlignment.start, backgroundColor: Colors.white,
children: [ appBar: AppBar(
const Text('入金を登録', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), automaticallyImplyLeading: false,
const SizedBox(height: 16), leading: IconButton(
TextField( icon: const Icon(Icons.close),
controller: _amountController, onPressed: _isSubmitting ? null : () => Navigator.pop(context),
keyboardType: TextInputType.number, ),
decoration: const InputDecoration(labelText: '入金額 (円)', border: OutlineInputBorder()), title: const ScreenAppBarTitle(screenId: 'S5', title: '入金登録'),
), actions: [
const SizedBox(height: 12), TextButton(
ListTile( onPressed: _isSubmitting ? null : _submit,
contentPadding: EdgeInsets.zero, child: _isSubmitting
title: const Text('入金日'), ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))
subtitle: Text(dateLabel), : const Text('保存'),
trailing: IconButton(icon: const Icon(Icons.calendar_today), onPressed: _pickDate), ),
),
DropdownButtonFormField<PaymentMethod>(
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('登録')),
], ],
), ),
], 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<PaymentMethod>(
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<SalesOrderEditorPage> {
} }
Future<void> _pickCustomer() async { Future<void> _pickCustomer() async {
final selected = await showModalBottomSheet<Customer?>( final selected = await showFeatureModalBottomSheet<Customer?>(
context: context, context: context,
isScrollControlled: true,
builder: (ctx) => CustomerPickerModal( builder: (ctx) => CustomerPickerModal(
onCustomerSelected: (customer) { onCustomerSelected: (customer) {
Navigator.pop(ctx, customer); Navigator.pop(ctx, customer);
@ -2022,10 +2088,16 @@ class _SalesOrderEditorPageState extends State<SalesOrderEditorPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final title = widget.order == null ? '受注の新規登録' : '受注を編集'; final title = widget.order == null ? '受注の新規登録' : '受注を編集';
final screenTitle = widget.order == null ? '受注登録' : '受注編集';
final mediaQuery = MediaQuery.of(context);
final bottomInset = mediaQuery.viewInsets.bottom;
return Scaffold( return Scaffold(
backgroundColor: Colors.grey.shade200,
resizeToAvoidBottomInset: false,
appBar: AppBar( appBar: AppBar(
leading: const BackButton(), leading: const BackButton(),
title: Text(title == '受注の新規登録' ? 'S6:受注登録' : 'S6:受注編集'), title: ScreenAppBarTitle(screenId: 'S6', title: screenTitle),
actions: [ actions: [
TextButton( TextButton(
onPressed: _isSaving ? null : _save, onPressed: _isSaving ? null : _save,
@ -2035,55 +2107,64 @@ class _SalesOrderEditorPageState extends State<SalesOrderEditorPage> {
), ),
], ],
), ),
body: SafeArea( body: MediaQuery(
child: GestureDetector( data: mediaQuery.removeViewInsets(removeBottom: true),
onTap: () => FocusScope.of(context).unfocus(), child: SafeArea(
child: ListView( top: false,
padding: const EdgeInsets.all(20), child: GestureDetector(
children: [ behavior: HitTestBehavior.opaque,
ListTile( onTap: () => FocusScope.of(context).unfocus(),
contentPadding: EdgeInsets.zero, child: SingleChildScrollView(
title: const Text('取引先'), keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
subtitle: Text(_customerName ?? '未選択'), padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + 32 + bottomInset),
trailing: OutlinedButton.icon( child: Column(
onPressed: _pickCustomer, crossAxisAlignment: CrossAxisAlignment.start,
icon: const Icon(Icons.search), children: [
label: Text(_customerName == null ? '選択' : '変更'), 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),
],
), ),
), ),
), ),

View file

@ -4,6 +4,8 @@ import 'package:uuid/uuid.dart';
import '../models/supplier_model.dart'; import '../models/supplier_model.dart';
import '../services/supplier_repository.dart'; import '../services/supplier_repository.dart';
import '../widgets/keyboard_inset_wrapper.dart'; import '../widgets/keyboard_inset_wrapper.dart';
import '../widgets/modal_utils.dart';
import '../widgets/screen_id_title.dart';
class SupplierPickerModal extends StatefulWidget { class SupplierPickerModal extends StatefulWidget {
const SupplierPickerModal({super.key, required this.onSupplierSelected}); const SupplierPickerModal({super.key, required this.onSupplierSelected});
@ -42,9 +44,12 @@ class _SupplierPickerModalState extends State<SupplierPickerModal> {
} }
Future<void> _openEditor({Supplier? supplier}) async { Future<void> _openEditor({Supplier? supplier}) async {
final result = await showDialog<Supplier>( final result = await showFeatureModalBottomSheet<Supplier>(
context: context, 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; if (result == null) return;
final saving = result.copyWith(id: result.id.isEmpty ? _uuid.v4() : result.id, updatedAt: DateTime.now()); final saving = result.copyWith(id: result.id.isEmpty ? _uuid.v4() : result.id, updatedAt: DateTime.now());
@ -54,8 +59,6 @@ class _SupplierPickerModalState extends State<SupplierPickerModal> {
await _loadSuppliers(_searchController.text); await _loadSuppliers(_searchController.text);
if (!mounted) return; if (!mounted) return;
widget.onSupplierSelected(saving); widget.onSupplierSelected(saving);
if (!mounted) return;
Navigator.pop(context);
} }
Future<void> _deleteSupplier(Supplier supplier) async { Future<void> _deleteSupplier(Supplier supplier) async {
@ -79,107 +82,123 @@ class _SupplierPickerModalState extends State<SupplierPickerModal> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
return SafeArea( return SafeArea(
child: Container( child: ClipRRect(
decoration: const BoxDecoration( borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
color: Colors.white, child: Scaffold(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)), backgroundColor: Colors.white,
), appBar: AppBar(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), automaticallyImplyLeading: false,
child: Column( leading: IconButton(
mainAxisSize: MainAxisSize.min, icon: const Icon(Icons.close),
children: [ onPressed: () => Navigator.pop(context),
Row( ),
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: [ children: [
IconButton(onPressed: () => Navigator.pop(context), icon: const Icon(Icons.close)), TextField(
const SizedBox(width: 8), controller: _searchController,
const Text('仕入先を選択', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), decoration: InputDecoration(
const Spacer(), hintText: '仕入先名で検索',
IconButton(onPressed: () => _openEditor(), icon: const Icon(Icons.add_circle_outline)), 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<String>(
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<String>(
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 { class _SupplierFormSheet extends StatefulWidget {
const _SupplierFormDialog({required this.onSubmit, this.supplier}); const _SupplierFormSheet({required this.onSubmit, this.supplier});
final Supplier? supplier; final Supplier? supplier;
final ValueChanged<Supplier> onSubmit; final ValueChanged<Supplier> onSubmit;
@override @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 _nameController;
late final TextEditingController _contactController; late final TextEditingController _contactController;
late final TextEditingController _telController; late final TextEditingController _telController;
@ -187,6 +206,7 @@ class _SupplierFormDialogState extends State<_SupplierFormDialog> {
late final TextEditingController _notesController; late final TextEditingController _notesController;
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
bool _isSaving = false;
@override @override
void initState() { void initState() {
@ -199,57 +219,91 @@ class _SupplierFormDialogState extends State<_SupplierFormDialog> {
_notesController = TextEditingController(text: supplier?.notes ?? ''); _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AlertDialog( final title = widget.supplier == null ? '仕入先を追加' : '仕入先を編集';
title: Text(widget.supplier == null ? '仕入先を追加' : '仕入先を編集'), return SafeArea(
content: KeyboardInsetWrapper( child: ClipRRect(
basePadding: const EdgeInsets.only(bottom: 8), borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
extraBottom: 24, child: Material(
child: Form( color: Colors.white,
key: _formKey, child: Column(
child: SingleChildScrollView( children: [
child: Column( Padding(
mainAxisSize: MainAxisSize.min, padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
children: [ child: Row(
TextFormField( children: [
controller: _nameController, IconButton(onPressed: () => Navigator.pop(context), icon: const Icon(Icons.close)),
decoration: const InputDecoration(labelText: '仕入先名 *'), const SizedBox(width: 4),
validator: (value) => value == null || value.trim().isEmpty ? '必須項目です' : null, 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: '担当者')), Expanded(
const SizedBox(height: 12), child: KeyboardInsetWrapper(
TextFormField(controller: _telController, decoration: const InputDecoration(labelText: '電話番号')), safeAreaTop: false,
const SizedBox(height: 12), basePadding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
TextFormField(controller: _emailController, decoration: const InputDecoration(labelText: 'メール')), extraBottom: 24,
const SizedBox(height: 12), child: Form(
TextFormField(controller: _notesController, decoration: const InputDecoration(labelText: '備考'), maxLines: 3), 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('保存'),
),
],
); );
} }
} }

View file

@ -8,6 +8,8 @@ class KeyboardInsetWrapper extends StatelessWidget {
final double extraBottom; final double extraBottom;
final Duration duration; final Duration duration;
final Curve curve; final Curve curve;
final bool safeAreaTop;
final bool safeAreaBottom;
const KeyboardInsetWrapper({ const KeyboardInsetWrapper({
super.key, super.key,
@ -16,6 +18,8 @@ class KeyboardInsetWrapper extends StatelessWidget {
this.extraBottom = 0, this.extraBottom = 0,
this.duration = const Duration(milliseconds: 180), this.duration = const Duration(milliseconds: 180),
this.curve = Curves.easeOut, this.curve = Curves.easeOut,
this.safeAreaTop = true,
this.safeAreaBottom = true,
}); });
@override @override
@ -23,15 +27,15 @@ class KeyboardInsetWrapper extends StatelessWidget {
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
final bottomInset = mediaQuery.viewInsets.bottom; final bottomInset = mediaQuery.viewInsets.bottom;
final padding = basePadding.add(EdgeInsets.only(bottom: bottomInset + extraBottom)); final padding = basePadding.add(EdgeInsets.only(bottom: bottomInset + extraBottom));
return MediaQuery( final applyBottomSafeArea = safeAreaBottom && bottomInset == 0;
data: mediaQuery.removeViewInsets(removeBottom: true), return SafeArea(
child: SafeArea( top: safeAreaTop,
child: AnimatedPadding( bottom: applyBottomSafeArea,
duration: duration, child: AnimatedPadding(
curve: curve, duration: duration,
padding: padding, curve: curve,
child: child, padding: padding,
), child: child,
), ),
); );
} }

View file

@ -0,0 +1,20 @@
import 'package:flutter/material.dart';
/// Presents a rounded feature modal with consistent safe-area handling.
Future<T?> showFeatureModalBottomSheet<T>({
required BuildContext context,
required WidgetBuilder builder,
double heightFactor = 0.9,
bool isScrollControlled = true,
Color backgroundColor = Colors.transparent,
}) {
return showModalBottomSheet<T>(
context: context,
isScrollControlled: isScrollControlled,
backgroundColor: backgroundColor,
builder: (sheetContext) => FractionallySizedBox(
heightFactor: heightFactor,
child: builder(sheetContext),
),
);
}