分割の後片付け終了

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

View file

@ -299,15 +299,17 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) {
return AlertDialog(
final inset = MediaQuery.of(context).viewInsets.bottom;
return MediaQuery.removeViewInsets(
removeBottom: true,
context: context,
child: AlertDialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
contentPadding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
title: Text(isEdit ? "顧客を編集" : "顧客を新規登録"),
content: KeyboardInsetWrapper(
basePadding: const EdgeInsets.fromLTRB(0, 0, 0, 12),
extraBottom: 32,
child: SingleChildScrollView(
content: SingleChildScrollView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.only(bottom: 24),
padding: EdgeInsets.only(bottom: inset + 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@ -396,7 +398,7 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
],
),
),
),
actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
TextButton(
@ -423,6 +425,7 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
child: const Text("保存"),
),
],
),
);
},
),
@ -663,6 +666,7 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar(
leading: const BackButton(),
title: Text(widget.selectionMode ? "C2:顧客選択" : "C1:顧客一覧"),
@ -712,12 +716,14 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
),
],
),
body: KeyboardInsetWrapper(
basePadding: const EdgeInsets.fromLTRB(0, 8, 0, 80),
extraBottom: 40,
child: Column(
children: [
Padding(
body: Padding(
padding: const EdgeInsets.only(top: 8, bottom: 8),
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(12),
child: TextField(
controller: _searchController,
@ -731,8 +737,10 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
onChanged: (_) => setState(_applyFilter),
),
),
),
if (!widget.selectionMode)
Padding(
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: SwitchListTile(
title: const Text('株式会社/有限会社などの接頭辞を無視してソート'),
@ -743,13 +751,21 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
}),
),
),
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _filtered.isEmpty
? const Center(child: Text("顧客が登録されていません"))
: ListView.builder(
padding: const EdgeInsets.only(bottom: 120, top: 4),
),
if (_isLoading)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
else if (_filtered.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: Text("顧客が登録されていません")),
)
else
SliverPadding(
padding: const EdgeInsets.only(bottom: 80, top: 4),
sliver: SliverList.builder(
itemCount: _filtered.length,
itemBuilder: (context, index) {
final c = _filtered[index];
@ -782,12 +798,16 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
],
),
),
floatingActionButton: FloatingActionButton.extended(
floatingActionButton: Builder(
builder: (context) {
return FloatingActionButton.extended(
onPressed: _showAddMenu,
icon: const Icon(Icons.add),
label: Text(widget.selectionMode ? "選択" : "追加"),
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
);
},
),
);
}

View file

@ -222,11 +222,13 @@ class _CustomerPickerModalState extends State<CustomerPickerModal> {
Widget build(BuildContext context) {
return Material(
child: KeyboardInsetWrapper(
basePadding: const EdgeInsets.fromLTRB(0, 0, 0, 24),
extraBottom: 24,
child: Column(
children: [
Padding(
basePadding: const EdgeInsets.fromLTRB(0, 0, 0, 16),
extraBottom: 32,
child: CustomScrollView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -262,15 +264,22 @@ class _CustomerPickerModalState extends State<CustomerPickerModal> {
],
),
),
const Divider(height: 1),
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _filteredCustomers.isEmpty
? const Center(child: Text("該当する顧客がいません"))
: ListView.builder(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.only(bottom: 80),
),
const SliverToBoxAdapter(child: Divider(height: 1)),
if (_isLoading)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
else if (_filteredCustomers.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: Text("該当する顧客がいません")),
)
else
SliverPadding(
padding: const EdgeInsets.only(bottom: 120),
sliver: SliverList.builder(
itemCount: _filteredCustomers.length,
itemBuilder: (context, index) {
final customer = _filteredCustomers[index];

View file

@ -11,7 +11,7 @@ import 'invoice_input_screen.dart';
import 'settings_screen.dart';
import 'company_info_screen.dart';
import '../widgets/slide_to_unlock.dart';
import '../main.dart'; // InvoiceFlowScreen
// InvoiceFlowScreen import removed; using inline type picker
import 'package:package_info_plus/package_info_plus.dart';
import '../widgets/invoice_pdf_preview_page.dart';
import 'invoice_history/invoice_history_list.dart';
@ -375,15 +375,7 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _isUnlocked
? () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => InvoiceFlowScreen(onComplete: _loadData),
),
);
_loadData();
}
? () => _showCreateTypeMenu()
: _requireUnlock,
label: const Text("新規伝票作成"),
icon: const Icon(Icons.add),
@ -392,4 +384,51 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
),
);
}
void _showCreateTypeMenu() {
showModalBottomSheet(
context: context,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.insert_drive_file_outlined),
title: const Text('下書き: 見積書', style: TextStyle(fontSize: 24)),
onTap: () => _startNew(DocumentType.estimation),
),
ListTile(
leading: const Icon(Icons.local_shipping_outlined),
title: const Text('下書き: 納品書', style: TextStyle(fontSize: 24)),
onTap: () => _startNew(DocumentType.delivery),
),
ListTile(
leading: const Icon(Icons.request_quote_outlined),
title: const Text('下書き: 請求書', style: TextStyle(fontSize: 24)),
onTap: () => _startNew(DocumentType.invoice),
),
ListTile(
leading: const Icon(Icons.receipt_long_outlined),
title: const Text('下書き: 領収書', style: TextStyle(fontSize: 24)),
onTap: () => _startNew(DocumentType.receipt),
),
],
),
),
);
}
Future<void> _startNew(DocumentType type) async {
Navigator.pop(context);
await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => InvoiceInputForm(
onInvoiceGenerated: (inv, path) {},
initialDocumentType: type,
),
),
);
_loadData();
}
}

View file

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

View file

@ -3,10 +3,11 @@ import 'package:uuid/uuid.dart';
import '../models/product_model.dart';
import '../services/product_repository.dart';
import 'barcode_scanner_screen.dart';
import '../widgets/keyboard_inset_wrapper.dart';
class ProductMasterScreen extends StatefulWidget {
const ProductMasterScreen({super.key});
final bool selectionMode;
const ProductMasterScreen({super.key, this.selectionMode = false});
@override
State<ProductMasterScreen> createState() => _ProductMasterScreenState();
@ -59,12 +60,17 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
final result = await showDialog<Product>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
builder: (context, setDialogState) {
final inset = MediaQuery.of(context).viewInsets.bottom;
return MediaQuery.removeViewInsets(
removeBottom: true,
context: context,
child: AlertDialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
title: Text(product == null ? "商品追加" : "商品編集"),
content: KeyboardInsetWrapper(
basePadding: EdgeInsets.zero,
extraBottom: 16,
child: SingleChildScrollView(
content: SingleChildScrollView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: EdgeInsets.only(bottom: inset + 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@ -95,7 +101,6 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
],
),
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
ElevatedButton(
@ -118,6 +123,8 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
),
],
),
);
},
),
);
@ -131,6 +138,7 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar(
leading: const BackButton(),
title: const Text("P1:商品マスター"),
@ -157,15 +165,15 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
),
),
),
body: KeyboardInsetWrapper(
basePadding: EdgeInsets.zero,
extraBottom: 72,
body: Padding(
padding: const EdgeInsets.only(top: 8, bottom: 8),
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _filteredProducts.isEmpty
? const Center(child: Text("商品が見つかりません"))
: ListView.builder(
padding: const EdgeInsets.only(bottom: 120, top: 8),
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 80, top: 8),
itemCount: _filteredProducts.length,
itemBuilder: (context, index) {
final p = _filteredProducts[index];
@ -182,8 +190,59 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
),
title: Text(p.name, style: TextStyle(fontWeight: FontWeight.bold, color: p.isLocked ? Colors.grey : Colors.black87)),
subtitle: Text("${p.category ?? '未分類'} - ¥${p.defaultUnitPrice} (在庫: ${p.stockQuantity})"),
onTap: () => _showDetailPane(p),
trailing: IconButton(
onTap: () {
if (widget.selectionMode) {
Navigator.pop(context, p);
} else {
_showDetailPane(p);
}
},
onLongPress: () async {
await showModalBottomSheet(
context: context,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.edit),
title: const Text("編集"),
onTap: () {
Navigator.pop(ctx);
_showEditDialog(product: p);
},
),
if (!p.isLocked)
ListTile(
leading: const Icon(Icons.delete_outline, color: Colors.redAccent),
title: const Text("削除", style: TextStyle(color: Colors.redAccent)),
onTap: () async {
Navigator.pop(ctx);
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text("削除の確認"),
content: Text("${p.name} を削除しますか?"),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("キャンセル")),
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text("削除", style: TextStyle(color: Colors.red))),
],
),
);
if (confirmed == true) {
await _productRepo.deleteProduct(p.id);
if (mounted) _loadProducts();
}
},
),
],
),
),
);
},
trailing: widget.selectionMode
? null
: IconButton(
icon: const Icon(Icons.edit),
onPressed: p.isLocked ? null : () => _showEditDialog(product: p),
tooltip: p.isLocked ? "ロック中" : "編集",

View file

@ -45,12 +45,15 @@ class _ProductPickerModalState extends State<ProductPickerModal> {
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.fromLTRB(8, 8, 16, 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
const SizedBox(width: 4),
const Text("商品・サービス選択", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)),
],
),
),
@ -101,14 +104,63 @@ class _ProductPickerModalState extends State<ProductPickerModal> {
leading: const Icon(Icons.inventory_2_outlined),
title: Text(product.name),
subtitle: Text("${product.defaultUnitPrice} (在庫: ${product.stockQuantity})"),
onTap: () => widget.onItemSelected(
onTap: () {
widget.onItemSelected(
InvoiceItem(
productId: product.id,
description: product.name,
quantity: 1,
unitPrice: product.defaultUnitPrice,
),
);
Navigator.pop(context);
},
onLongPress: () async {
await showModalBottomSheet(
context: context,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.edit),
title: const Text("編集"),
onTap: () async {
Navigator.pop(ctx);
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const ProductMasterScreen()),
);
_onSearch(_searchController.text);
},
),
ListTile(
leading: const Icon(Icons.delete_outline, color: Colors.redAccent),
title: const Text("削除", style: TextStyle(color: Colors.redAccent)),
onTap: () async {
Navigator.pop(ctx);
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text("削除の確認"),
content: Text("${product.name} を削除しますか?"),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("キャンセル")),
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text("削除", style: TextStyle(color: Colors.red))),
],
),
);
if (confirmed == true) {
await _productRepo.deleteProduct(product.id);
if (mounted) _onSearch(_searchController.text);
}
},
),
],
),
),
);
},
);
},
),

View file

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