売上管理関連スクリーン画面の修正
This commit is contained in:
parent
c98dd3cc72
commit
a2f7013984
9 changed files with 750 additions and 567 deletions
|
|
@ -224,6 +224,7 @@ class _CustomerPickerModalState extends State<CustomerPickerModal> {
|
|||
@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<CustomerPickerModal> {
|
|||
),
|
||||
);
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ProductPickerModal> {
|
|||
|
||||
@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<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);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PurchaseEntryEditorPage> {
|
|||
}
|
||||
|
||||
Future<void> _pickSupplier() async {
|
||||
await showModalBottomSheet<Supplier>(
|
||||
final selected = await showFeatureModalBottomSheet<Supplier>(
|
||||
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<PurchaseEntryEditorPage> {
|
|||
}
|
||||
|
||||
Future<void> _pickProduct(int index) async {
|
||||
await showModalBottomSheet<void>(
|
||||
await showFeatureModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => ProductPickerModal(
|
||||
onItemSelected: (_) {},
|
||||
onProductSelected: (product) {
|
||||
|
|
@ -350,6 +348,7 @@ class _PurchaseEntryEditorPageState extends State<PurchaseEntryEditorPage> {
|
|||
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<PurchaseEntryEditorPage> {
|
|||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<PurchaseReceiptEditorPage> {
|
|||
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<PurchaseReceiptEditorPage> {
|
|||
),
|
||||
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: [
|
||||
|
|
|
|||
|
|
@ -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<void> _pickCustomer() async {
|
||||
final selected = await showModalBottomSheet<Customer?>(
|
||||
final selected = await showFeatureModalBottomSheet<Customer?>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder: (ctx) => CustomerPickerModal(
|
||||
onCustomerSelected: (customer) {
|
||||
Navigator.pop(ctx, customer);
|
||||
|
|
|
|||
|
|
@ -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<ShipmentEditorPage> {
|
|||
|
||||
@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<ShipmentEditorPage> {
|
|||
),
|
||||
],
|
||||
),
|
||||
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<SalesInventoryScreen> {
|
|||
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<InventoryMovementType>(
|
||||
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<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();
|
||||
|
||||
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<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('登録')),
|
||||
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<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 {
|
||||
final selected = await showModalBottomSheet<Customer?>(
|
||||
final selected = await showFeatureModalBottomSheet<Customer?>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => CustomerPickerModal(
|
||||
onCustomerSelected: (customer) {
|
||||
Navigator.pop(ctx, customer);
|
||||
|
|
@ -2022,10 +2088,16 @@ class _SalesOrderEditorPageState extends State<SalesOrderEditorPage> {
|
|||
@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<SalesOrderEditorPage> {
|
|||
),
|
||||
],
|
||||
),
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<SupplierPickerModal> {
|
|||
}
|
||||
|
||||
Future<void> _openEditor({Supplier? supplier}) async {
|
||||
final result = await showDialog<Supplier>(
|
||||
final result = await showFeatureModalBottomSheet<Supplier>(
|
||||
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<SupplierPickerModal> {
|
|||
await _loadSuppliers(_searchController.text);
|
||||
if (!mounted) return;
|
||||
widget.onSupplierSelected(saving);
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
Future<void> _deleteSupplier(Supplier supplier) async {
|
||||
|
|
@ -79,107 +82,123 @@ class _SupplierPickerModalState extends State<SupplierPickerModal> {
|
|||
|
||||
@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<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 {
|
||||
const _SupplierFormDialog({required this.onSubmit, this.supplier});
|
||||
class _SupplierFormSheet extends StatefulWidget {
|
||||
const _SupplierFormSheet({required this.onSubmit, this.supplier});
|
||||
|
||||
final Supplier? supplier;
|
||||
final ValueChanged<Supplier> 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<FormState>();
|
||||
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('保存'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
20
lib/widgets/modal_utils.dart
Normal file
20
lib/widgets/modal_utils.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue