分割に失敗、修復中

This commit is contained in:
joe 2026-02-26 17:29:22 +09:00
parent 504a5a60cc
commit c01a0b6775
11 changed files with 966 additions and 764 deletions

View file

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import '../models/company_model.dart'; import '../models/company_model.dart';
import '../services/company_repository.dart'; import '../services/company_repository.dart';
import '../widgets/keyboard_inset_wrapper.dart';
class CompanyInfoScreen extends StatefulWidget { class CompanyInfoScreen extends StatefulWidget {
const CompanyInfoScreen({Key? key}) : super(key: key); const CompanyInfoScreen({Key? key}) : super(key: key);
@ -76,70 +77,74 @@ class _CompanyInfoScreenState extends State<CompanyInfoScreen> {
IconButton(icon: const Icon(Icons.check), onPressed: _save), IconButton(icon: const Icon(Icons.check), onPressed: _save),
], ],
), ),
body: SingleChildScrollView( body: KeyboardInsetWrapper(
padding: const EdgeInsets.all(16), basePadding: const EdgeInsets.all(16),
child: Column( extraBottom: 32,
crossAxisAlignment: CrossAxisAlignment.start, child: SingleChildScrollView(
children: [ keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
_buildTextField("自社名", _nameController), child: Column(
const SizedBox(height: 12), crossAxisAlignment: CrossAxisAlignment.start,
_buildTextField("郵便番号", _zipController), children: [
const SizedBox(height: 12), _buildTextField("自社名", _nameController),
_buildTextField("住所", _addressController), const SizedBox(height: 12),
const SizedBox(height: 12), _buildTextField("郵便番号", _zipController),
_buildTextField("電話番号", _telController), const SizedBox(height: 12),
const SizedBox(height: 20), _buildTextField("住所", _addressController),
const Text("デフォルト消費税率", style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 12),
Row( _buildTextField("電話番号", _telController),
children: [ const SizedBox(height: 20),
ChoiceChip(label: const Text("10%"), selected: _taxRate == 0.10, onSelected: (_) => setState(() => _taxRate = 0.10)), const Text("デフォルト消費税率", style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(width: 8), Row(
ChoiceChip(label: const Text("8%"), selected: _taxRate == 0.08, onSelected: (_) => setState(() => _taxRate = 0.08)), children: [
], ChoiceChip(label: const Text("10%"), selected: _taxRate == 0.10, onSelected: (_) => setState(() => _taxRate = 0.10)),
), const SizedBox(width: 8),
const SizedBox(height: 20), ChoiceChip(label: const Text("8%"), selected: _taxRate == 0.08, onSelected: (_) => setState(() => _taxRate = 0.08)),
const Text("消費税の表示設定T番号非取得時など", style: TextStyle(fontWeight: FontWeight.bold)), ],
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
ChoiceChip(
label: const Text("通常表示"),
selected: _taxDisplayMode == 'normal',
onSelected: (_) => setState(() => _taxDisplayMode = 'normal'),
),
ChoiceChip(
label: const Text("表示しない"),
selected: _taxDisplayMode == 'hidden',
onSelected: (_) => setState(() => _taxDisplayMode = 'hidden'),
),
ChoiceChip(
label: const Text("「税別」と表示"),
selected: _taxDisplayMode == 'text_only',
onSelected: (_) => setState(() => _taxDisplayMode = 'text_only'),
),
],
),
const SizedBox(height: 24),
const Text("印影(角印)撮影", style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
GestureDetector(
onTap: _pickImage,
child: Container(
height: 150,
width: 150,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(8),
),
child: _info.sealPath != null
? Image.file(File(_info.sealPath!), fit: BoxFit.contain)
: const Center(child: Icon(Icons.camera_alt, size: 50, color: Colors.grey)),
), ),
), const SizedBox(height: 20),
const SizedBox(height: 8), const Text("消費税の表示設定T番号非取得時など", style: TextStyle(fontWeight: FontWeight.bold)),
const Text("白い紙に押した判子を真上から撮影してください", style: TextStyle(fontSize: 12, color: Colors.grey)), const SizedBox(height: 8),
], Wrap(
spacing: 8,
children: [
ChoiceChip(
label: const Text("通常表示"),
selected: _taxDisplayMode == 'normal',
onSelected: (_) => setState(() => _taxDisplayMode = 'normal'),
),
ChoiceChip(
label: const Text("表示しない"),
selected: _taxDisplayMode == 'hidden',
onSelected: (_) => setState(() => _taxDisplayMode = 'hidden'),
),
ChoiceChip(
label: const Text("「税別」と表示"),
selected: _taxDisplayMode == 'text_only',
onSelected: (_) => setState(() => _taxDisplayMode = 'text_only'),
),
],
),
const SizedBox(height: 24),
const Text("印影(角印)撮影", style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
GestureDetector(
onTap: _pickImage,
child: Container(
height: 150,
width: 150,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(8),
),
child: _info.sealPath != null
? Image.file(File(_info.sealPath!), fit: BoxFit.contain)
: const Center(child: Icon(Icons.camera_alt, size: 50, color: Colors.grey)),
),
),
const SizedBox(height: 8),
const Text("白い紙に押した判子を真上から撮影してください", style: TextStyle(fontSize: 12, color: Colors.grey)),
],
),
), ),
), ),
); );

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import 'package:flutter_contacts/flutter_contacts.dart'; import 'package:flutter_contacts/flutter_contacts.dart';
import '../widgets/keyboard_inset_wrapper.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert'; import 'dart:convert';
import '../models/customer_model.dart'; import '../models/customer_model.dart';
@ -315,141 +316,148 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
final result = await showDialog<Customer>( final result = await showDialog<Customer>(
context: context, context: context,
builder: (context) => StatefulBuilder( builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog( builder: (context, setDialogState) {
title: Text(isEdit ? "顧客を編集" : "顧客を新規登録"), return AlertDialog(
content: SingleChildScrollView( title: Text(isEdit ? "顧客を編集" : "顧客を新規登録"),
child: Column( content: KeyboardInsetWrapper(
mainAxisSize: MainAxisSize.min, basePadding: const EdgeInsets.fromLTRB(0, 0, 0, 12),
children: [ extraBottom: 20,
TextField( child: SingleChildScrollView(
controller: displayNameController, keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
decoration: const InputDecoration(labelText: "表示名(略称)", hintText: "例: 佐々木製作所"), child: Column(
onChanged: (v) { mainAxisSize: MainAxisSize.min,
if (head1Controller.text.isEmpty) {
head1Controller.text = _headKana(v);
}
},
),
TextField(
controller: formalNameController,
decoration: const InputDecoration(labelText: "正式名称", hintText: "例: 株式会社 佐々木製作所"),
),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
icon: const Icon(Icons.contact_phone),
label: const Text('電話帳から引用'),
onPressed: prefillFromPhonebook,
),
),
Row(
children: [ children: [
Expanded( TextField(
child: RadioListTile<bool>( controller: displayNameController,
dense: true, decoration: const InputDecoration(labelText: "表示名(略称)", hintText: "例: 佐々木製作所"),
title: const Text('会社'), onChanged: (v) {
value: true, if (head1Controller.text.isEmpty) {
groupValue: isCompany, head1Controller.text = _headKana(v);
onChanged: (v) { }
setDialogState(() { },
isCompany = v ?? true; ),
selectedTitle = '御中'; TextField(
}); controller: formalNameController,
}, decoration: const InputDecoration(labelText: "正式名称", hintText: "例: 株式会社 佐々木製作所"),
),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
icon: const Icon(Icons.contact_phone),
label: const Text('電話帳から引用'),
onPressed: prefillFromPhonebook,
), ),
), ),
Expanded( Row(
child: RadioListTile<bool>( children: [
dense: true, Expanded(
title: const Text('個人'), child: RadioListTile<bool>(
value: false, dense: true,
groupValue: isCompany, title: const Text('会社'),
onChanged: (v) { value: true,
setDialogState(() { groupValue: isCompany,
isCompany = v ?? false; onChanged: (v) {
selectedTitle = ''; setDialogState(() {
}); isCompany = v ?? true;
}, selectedTitle = '御中';
), });
},
),
),
Expanded(
child: RadioListTile<bool>(
dense: true,
title: const Text('個人'),
value: false,
groupValue: isCompany,
onChanged: (v) {
setDialogState(() {
isCompany = v ?? false;
selectedTitle = '';
});
},
),
),
],
),
DropdownButtonFormField<String>(
value: selectedTitle,
decoration: const InputDecoration(labelText: "敬称"),
items: ["", "御中", "殿", "貴社"].map((t) => DropdownMenuItem(value: t, child: Text(t))).toList(),
onChanged: (val) => setDialogState(() {
selectedTitle = val ?? "";
isCompany = selectedTitle == '御中' || selectedTitle == '貴社';
}),
),
Row(
children: [
Expanded(
child: TextField(
controller: head1Controller,
maxLength: 1,
decoration: const InputDecoration(labelText: "インデックス1 (1文字)", counterText: ""),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: head2Controller,
maxLength: 1,
decoration: const InputDecoration(labelText: "インデックス2 (任意)", counterText: ""),
),
),
],
),
TextField(
controller: departmentController,
decoration: const InputDecoration(labelText: "部署名", hintText: "例: 営業部"),
),
TextField(
controller: addressController,
decoration: const InputDecoration(labelText: "住所"),
),
TextField(
controller: telController,
decoration: const InputDecoration(labelText: "電話番号"),
keyboardType: TextInputType.phone,
),
TextField(
controller: emailController,
decoration: const InputDecoration(labelText: "メールアドレス"),
keyboardType: TextInputType.emailAddress,
), ),
], ],
), ),
DropdownButtonFormField<String>( ),
value: selectedTitle,
decoration: const InputDecoration(labelText: "敬称"),
items: ["", "御中", "殿", "貴社"].map((t) => DropdownMenuItem(value: t, child: Text(t))).toList(),
onChanged: (val) => setDialogState(() {
selectedTitle = val ?? "";
isCompany = selectedTitle == '御中' || selectedTitle == '貴社';
}),
),
Row(
children: [
Expanded(
child: TextField(
controller: head1Controller,
maxLength: 1,
decoration: const InputDecoration(labelText: "インデックス1 (1文字)", counterText: ""),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: head2Controller,
maxLength: 1,
decoration: const InputDecoration(labelText: "インデックス2 (任意)", counterText: ""),
),
),
],
),
TextField(
controller: departmentController,
decoration: const InputDecoration(labelText: "部署名", hintText: "例: 営業部"),
),
TextField(
controller: addressController,
decoration: const InputDecoration(labelText: "住所"),
),
TextField(
controller: telController,
decoration: const InputDecoration(labelText: "電話番号"),
keyboardType: TextInputType.phone,
),
TextField(
controller: emailController,
decoration: const InputDecoration(labelText: "メールアドレス"),
keyboardType: TextInputType.emailAddress,
),
],
), ),
), actions: [
actions: [ TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")), TextButton(
TextButton( onPressed: () {
onPressed: () { if (displayNameController.text.isEmpty || formalNameController.text.isEmpty) {
if (displayNameController.text.isEmpty || formalNameController.text.isEmpty) { return;
return; }
} final head1 = _normalizeIndexChar(head1Controller.text);
final head1 = _normalizeIndexChar(head1Controller.text); final head2 = _normalizeIndexChar(head2Controller.text);
final head2 = _normalizeIndexChar(head2Controller.text); final newCustomer = Customer(
final newCustomer = Customer( id: customer?.id ?? const Uuid().v4(),
id: customer?.id ?? const Uuid().v4(), displayName: displayNameController.text,
displayName: displayNameController.text, formalName: formalNameController.text,
formalName: formalNameController.text, title: selectedTitle,
title: selectedTitle, department: departmentController.text.isEmpty ? null : departmentController.text,
department: departmentController.text.isEmpty ? null : departmentController.text, address: addressController.text.isEmpty ? null : addressController.text,
address: addressController.text.isEmpty ? null : addressController.text, tel: telController.text.isEmpty ? null : telController.text,
tel: telController.text.isEmpty ? null : telController.text, headChar1: head1.isEmpty ? _headKana(displayNameController.text) : head1,
headChar1: head1.isEmpty ? _headKana(displayNameController.text) : head1, headChar2: head2.isEmpty ? null : head2,
headChar2: head2.isEmpty ? null : head2, isLocked: customer?.isLocked ?? false,
isLocked: customer?.isLocked ?? false, );
); Navigator.pop(context, newCustomer);
Navigator.pop(context, newCustomer); },
}, child: const Text("保存"),
child: const Text("保存"), ),
), ],
], );
), },
), ),
); );
@ -741,76 +749,80 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
), ),
], ],
), ),
body: Column( body: KeyboardInsetWrapper(
children: [ basePadding: const EdgeInsets.fromLTRB(0, 8, 0, 80),
Padding( extraBottom: 40,
padding: const EdgeInsets.all(12), child: Column(
child: TextField( children: [
controller: _searchController,
decoration: InputDecoration(
hintText: widget.selectionMode ? "名前で検索して選択" : "名前で検索 (電話帳参照ボタンは詳細で)",
prefixIcon: const Icon(Icons.search),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
),
onChanged: (_) => setState(_applyFilter),
),
),
// Kana index temporarily disabled
if (!widget.selectionMode)
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.all(12),
child: SwitchListTile( child: TextField(
title: const Text('株式会社/有限会社などの接頭辞を無視してソート'), controller: _searchController,
value: _ignoreCorpPrefix, decoration: InputDecoration(
onChanged: (v) => setState(() { hintText: widget.selectionMode ? "名前で検索して選択" : "名前で検索 (電話帳参照ボタンは詳細で)",
_ignoreCorpPrefix = v; prefixIcon: const Icon(Icons.search),
_applyFilter(); filled: true,
}), fillColor: Colors.white,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
),
onChanged: (_) => setState(_applyFilter),
), ),
), ),
Expanded( if (!widget.selectionMode)
child: _isLoading Padding(
? const Center(child: CircularProgressIndicator()) padding: const EdgeInsets.symmetric(horizontal: 12),
: _filtered.isEmpty child: SwitchListTile(
? const Center(child: Text("顧客が登録されていません")) title: const Text('株式会社/有限会社などの接頭辞を無視してソート'),
: ListView.builder( value: _ignoreCorpPrefix,
itemCount: _filtered.length, onChanged: (v) => setState(() {
itemBuilder: (context, index) { _ignoreCorpPrefix = v;
final c = _filtered[index]; _applyFilter();
return ListTile( }),
leading: CircleAvatar( ),
backgroundColor: c.isLocked ? Colors.grey.shade300 : Colors.indigo.shade100, ),
child: Stack( Expanded(
children: [ child: _isLoading
const Align(alignment: Alignment.center, child: Icon(Icons.person, color: Colors.indigo)), ? const Center(child: CircularProgressIndicator())
if (c.isLocked) : _filtered.isEmpty
const Align(alignment: Alignment.bottomRight, child: Icon(Icons.lock, size: 14, color: Colors.redAccent)), ? const Center(child: Text("顧客が登録されていません"))
], : ListView.builder(
padding: const EdgeInsets.only(bottom: 120, top: 4),
itemCount: _filtered.length,
itemBuilder: (context, index) {
final c = _filtered[index];
return ListTile(
leading: CircleAvatar(
backgroundColor: c.isLocked ? Colors.grey.shade300 : Colors.indigo.shade100,
child: Stack(
children: [
const Align(alignment: Alignment.center, child: Icon(Icons.person, color: Colors.indigo)),
if (c.isLocked)
const Align(alignment: Alignment.bottomRight, child: Icon(Icons.lock, size: 14, color: Colors.redAccent)),
],
),
), ),
), title: Text(c.displayName, style: TextStyle(fontWeight: FontWeight.bold, color: c.isLocked ? Colors.grey : Colors.black87)),
title: Text(c.displayName, style: TextStyle(fontWeight: FontWeight.bold, color: c.isLocked ? Colors.grey : Colors.black87)), subtitle: Text("${c.formalName} ${c.title}"),
subtitle: Text("${c.formalName} ${c.title}"), onTap: widget.selectionMode ? () => Navigator.pop(context, c) : () => _showDetailPane(c),
onTap: widget.selectionMode ? () => Navigator.pop(context, c) : () => _showDetailPane(c), trailing: widget.selectionMode
trailing: widget.selectionMode ? null
? null : IconButton(
: IconButton( icon: const Icon(Icons.edit),
icon: const Icon(Icons.edit), onPressed: c.isLocked ? null : () => _addOrEditCustomer(customer: c),
onPressed: c.isLocked ? null : () => _addOrEditCustomer(customer: c), tooltip: c.isLocked ? "ロック中" : "編集",
tooltip: c.isLocked ? "ロック中" : "編集", ),
), onLongPress: () => _showContextActions(c),
onLongPress: () => _showContextActions(c), );
); },
}, ),
), ),
), ],
], ),
), ),
floatingActionButton: FloatingActionButton.extended( floatingActionButton: FloatingActionButton.extended(
onPressed: _showAddMenu, onPressed: _showAddMenu,
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: const Text('顧客を追加'), label: Text(widget.selectionMode ? "選択" : "追加"),
backgroundColor: Colors.indigo, backgroundColor: Colors.indigo,
foregroundColor: Colors.white, foregroundColor: Colors.white,
), ),
@ -963,7 +975,7 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () { onPressed: () {
Navigator.pop(context); Navigator.pop(context);
_showContactUpdateDialog(c); _showContactUpdateSheet(c);
}, },
icon: const Icon(Icons.contact_mail), icon: const Icon(Icons.contact_mail),
label: const Text("連絡先を更新"), label: const Text("連絡先を更新"),
@ -1006,4 +1018,38 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
), ),
); );
} }
void _showContactUpdateSheet(Customer c) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => KeyboardInsetWrapper(
basePadding: const EdgeInsets.fromLTRB(0, 0, 0, 12),
extraBottom: 16,
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.contact_mail),
title: const Text('連絡先を更新'),
onTap: () {
Navigator.pop(context);
_showContactUpdateSheet(c);
},
),
ListTile(
leading: const Icon(Icons.contact_phone),
title: const Text('電話帳から取り込む'),
onTap: () {
Navigator.pop(context);
_showPhonebookImport();
},
),
],
),
),
),
);
}
} }

View file

@ -3,6 +3,7 @@ import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../models/customer_model.dart'; import '../models/customer_model.dart';
import '../services/customer_repository.dart'; import '../services/customer_repository.dart';
import '../widgets/keyboard_inset_wrapper.dart';
/// ///
class CustomerPickerModal extends StatefulWidget { class CustomerPickerModal extends StatefulWidget {
@ -203,81 +204,87 @@ class _CustomerPickerModalState extends State<CustomerPickerModal> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return Material(
child: Column( child: KeyboardInsetWrapper(
children: [ basePadding: const EdgeInsets.fromLTRB(0, 0, 0, 24),
Padding( extraBottom: 24,
padding: const EdgeInsets.all(16.0), child: Column(
child: Column( children: [
crossAxisAlignment: CrossAxisAlignment.start, Padding(
children: [ padding: const EdgeInsets.all(16.0),
Row( child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text("顧客マスター管理", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), Row(
IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)), mainAxisAlignment: MainAxisAlignment.spaceBetween,
], children: [
), const Text("顧客マスター管理", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 12), IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)),
TextField( ],
decoration: InputDecoration(
hintText: "登録済み顧客を検索...",
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
), ),
onChanged: _onSearch, const SizedBox(height: 12),
), TextField(
const SizedBox(height: 12), decoration: InputDecoration(
SizedBox( hintText: "登録済み顧客を検索...",
width: double.infinity, prefixIcon: const Icon(Icons.search),
child: ElevatedButton.icon( border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
onPressed: _isImportingFromContacts ? null : _importFromPhoneContacts, ),
icon: _isImportingFromContacts onChanged: _onSearch,
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.contact_phone),
label: const Text("電話帳から新規取り込み"),
style: ElevatedButton.styleFrom(backgroundColor: Colors.blueGrey.shade700, foregroundColor: Colors.white),
), ),
), const SizedBox(height: 12),
], SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isImportingFromContacts ? null : _importFromPhoneContacts,
icon: _isImportingFromContacts
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.contact_phone),
label: const Text("電話帳から新規取り込み"),
style: ElevatedButton.styleFrom(backgroundColor: Colors.blueGrey.shade700, foregroundColor: Colors.white),
),
),
],
),
), ),
), const Divider(height: 1),
const Divider(), Expanded(
Expanded( child: _isLoading
child: _isLoading ? const Center(child: CircularProgressIndicator())
? const Center(child: CircularProgressIndicator()) : _filteredCustomers.isEmpty
: _filteredCustomers.isEmpty ? const Center(child: Text("該当する顧客がいません"))
? const Center(child: Text("該当する顧客がいません")) : ListView.builder(
: ListView.builder( keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
itemCount: _filteredCustomers.length, padding: const EdgeInsets.only(bottom: 80),
itemBuilder: (context, index) { itemCount: _filteredCustomers.length,
final customer = _filteredCustomers[index]; itemBuilder: (context, index) {
return ListTile( final customer = _filteredCustomers[index];
leading: const CircleAvatar(child: Icon(Icons.business)), return ListTile(
title: Text(customer.formalName), leading: const CircleAvatar(child: Icon(Icons.business)),
subtitle: Text(customer.department?.isNotEmpty == true ? customer.department! : "部署未設定"), title: Text(customer.formalName),
onTap: () => widget.onCustomerSelected(customer), subtitle: Text(customer.department?.isNotEmpty == true ? customer.department! : "部署未設定"),
trailing: Row( onTap: () => widget.onCustomerSelected(customer),
mainAxisSize: MainAxisSize.min, trailing: Row(
children: [ mainAxisSize: MainAxisSize.min,
IconButton( children: [
icon: const Icon(Icons.edit, color: Colors.blueGrey, size: 20), IconButton(
onPressed: () => _showCustomerEditDialog( icon: const Icon(Icons.edit, color: Colors.blueGrey, size: 20),
displayName: customer.displayName, onPressed: () => _showCustomerEditDialog(
initialFormalName: customer.formalName, displayName: customer.displayName,
existingCustomer: customer, initialFormalName: customer.formalName,
existingCustomer: customer,
),
), ),
), IconButton(
IconButton( icon: const Icon(Icons.delete_outline, color: Colors.redAccent, size: 20),
icon: const Icon(Icons.delete_outline, color: Colors.redAccent, size: 20), onPressed: () => _confirmDelete(customer),
onPressed: () => _confirmDelete(customer), ),
), ],
], ),
), );
); },
}, ),
), ),
), ],
], ),
), ),
); );
} }

View file

@ -13,6 +13,7 @@ import '../services/customer_repository.dart';
import '../services/company_repository.dart'; import '../services/company_repository.dart';
import 'product_picker_modal.dart'; import 'product_picker_modal.dart';
import '../models/company_model.dart'; import '../models/company_model.dart';
import '../widgets/keyboard_inset_wrapper.dart';
class InvoiceDetailPage extends StatefulWidget { class InvoiceDetailPage extends StatefulWidget {
final Invoice invoice; final Invoice invoice;
@ -158,6 +159,7 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
return Scaffold( return Scaffold(
backgroundColor: themeColor, backgroundColor: themeColor,
resizeToAvoidBottomInset: false,
appBar: AppBar( appBar: AppBar(
leading: const BackButton(), // leading: const BackButton(), //
title: Row( title: Row(
@ -245,81 +247,84 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
] ]
], ],
), ),
body: SingleChildScrollView( body: KeyboardInsetWrapper(
padding: const EdgeInsets.all(16.0), basePadding: const EdgeInsets.all(16.0),
child: Column( extraBottom: 48,
crossAxisAlignment: CrossAxisAlignment.start, child: SingleChildScrollView(
children: [ keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
if (isDraft) child: Column(
Container( crossAxisAlignment: CrossAxisAlignment.start,
width: double.infinity, children: [
padding: const EdgeInsets.all(8), if (isDraft)
margin: const EdgeInsets.only(bottom: 8), Container(
decoration: BoxDecoration( width: double.infinity,
color: Colors.orange.shade50, padding: const EdgeInsets.all(8),
borderRadius: BorderRadius.circular(8), margin: const EdgeInsets.only(bottom: 8),
border: Border.all(color: Colors.orange.shade200), decoration: BoxDecoration(
), color: Colors.orange.shade50,
child: Row( borderRadius: BorderRadius.circular(8),
children: const [ border: Border.all(color: Colors.orange.shade200),
Icon(Icons.edit_note, color: Colors.orange), ),
SizedBox(width: 8), child: Row(
Expanded( children: const [
child: Text( Icon(Icons.edit_note, color: Colors.orange),
"下書き: 未確定・PDFは正式発行で確定", SizedBox(width: 8),
style: TextStyle(color: Colors.orange), Expanded(
child: Text(
"下書き: 未確定・PDFは正式発行で確定",
style: TextStyle(color: Colors.orange),
),
), ),
), ],
], ),
), ),
), _buildHeaderSection(textColor),
_buildHeaderSection(textColor), if (_isEditing) ...[
if (_isEditing) ...[ const SizedBox(height: 16),
const SizedBox(height: 16), _buildDraftToggleEdit(), //
_buildDraftToggleEdit(), // const SizedBox(height: 16),
const SizedBox(height: 16), _buildExperimentalSection(isDraft),
_buildExperimentalSection(isDraft), ],
Divider(height: 32, color: Colors.grey.shade400),
Text("明細一覧", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: textColor)),
const SizedBox(height: 8),
_buildItemTable(fmt, textColor, isDraft),
if (_isEditing)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Wrap(
spacing: 12,
runSpacing: 8,
children: [
ElevatedButton.icon(
onPressed: _addItem,
icon: const Icon(Icons.add),
label: const Text("空の行を追加"),
),
ElevatedButton.icon(
onPressed: _pickFromMaster,
icon: const Icon(Icons.list_alt),
label: const Text("マスターから選択"),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueGrey.shade700,
foregroundColor: Colors.white,
),
),
],
),
),
const SizedBox(height: 24),
_buildSummarySection(fmt, textColor, isDraft),
const SizedBox(height: 24),
_buildFooterActions(),
], ],
Divider(height: 32, color: Colors.grey.shade400), ),
Text("明細一覧", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: textColor)),
const SizedBox(height: 8),
_buildItemTable(fmt, textColor, isDraft),
if (_isEditing)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Wrap(
spacing: 12,
runSpacing: 8,
children: [
ElevatedButton.icon(
onPressed: _addItem,
icon: const Icon(Icons.add),
label: const Text("空の行を追加"),
),
ElevatedButton.icon(
onPressed: _pickFromMaster,
icon: const Icon(Icons.list_alt),
label: const Text("マスターから選択"),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueGrey.shade700,
foregroundColor: Colors.white,
),
),
],
),
),
const SizedBox(height: 24),
_buildSummarySection(fmt, textColor, isDraft),
const SizedBox(height: 24),
_buildFooterActions(),
],
), ),
), ),
); );
} }
Widget _buildHeaderSection(Color textColor) { Widget _buildHeaderSection(Color textColor) {
final dateFormatter = DateFormat('yyyy年MM月dd日');
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -365,7 +370,7 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: textColor)), style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: textColor)),
if (_currentInvoice.subject?.isNotEmpty ?? false) ...[ if (_currentInvoice.subject?.isNotEmpty ?? false) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Text("件名: ${_currentInvoice.subject}", Text("件名: ${_currentInvoice.subject}",
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.indigoAccent)), style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.indigoAccent)),
], ],
if (_currentInvoice.customer.department != null && _currentInvoice.customer.department!.isNotEmpty) if (_currentInvoice.customer.department != null && _currentInvoice.customer.department!.isNotEmpty)

View file

@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../models/invoice_models.dart';
class InvoiceHistoryItem extends StatelessWidget {
final Invoice invoice;
final bool isUnlocked;
final NumberFormat amountFormatter;
final DateFormat dateFormatter;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final VoidCallback? onEdit;
const InvoiceHistoryItem({
Key? key,
required this.invoice,
required this.isUnlocked,
required this.amountFormatter,
required this.dateFormatter,
this.onTap,
this.onLongPress,
this.onEdit,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return ListTile(
tileColor: invoice.isDraft ? Colors.orange.shade50 : null,
leading: CircleAvatar(
backgroundColor: invoice.isDraft
? Colors.orange.shade100
: (isUnlocked ? Colors.indigo.shade100 : Colors.grey.shade200),
child: Stack(
children: [
Align(
alignment: Alignment.center,
child: Icon(
invoice.isDraft ? Icons.edit_note : Icons.description_outlined,
color: invoice.isDraft
? Colors.orange
: (isUnlocked ? Colors.indigo : Colors.grey),
),
),
if (invoice.isLocked)
const Align(
alignment: Alignment.bottomRight,
child: Icon(Icons.lock, size: 14, color: Colors.redAccent),
),
],
),
),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
invoice.customerNameForDisplay,
style: TextStyle(
fontWeight: FontWeight.bold,
color: invoice.isLocked ? Colors.grey : Colors.black87,
),
),
if (invoice.subject?.isNotEmpty ?? false)
Text(
invoice.subject!,
style: TextStyle(
fontSize: 13,
color: Colors.indigo.shade700,
fontWeight: FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
subtitle: Text("${dateFormatter.format(invoice.date)} - ${invoice.invoiceNumber}"),
trailing: SizedBox(
height: 48,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
"${amountFormatter.format(invoice.totalAmount)}",
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
),
if (invoice.isSynced)
const Icon(Icons.sync, size: 14, color: Colors.green)
else
const Icon(Icons.sync_disabled, size: 14, color: Colors.orange),
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(width: 28, height: 24),
icon: const Icon(Icons.edit, size: 16),
tooltip: invoice.isLocked
? "ロック中"
: (isUnlocked ? "編集" : "アンロックして編集"),
onPressed: (invoice.isLocked || !isUnlocked)
? null
: onEdit,
),
],
),
),
onTap: onTap,
onLongPress: onLongPress,
);
}
}

View file

@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../models/invoice_models.dart';
import 'invoice_history_item.dart';
class InvoiceHistoryList extends StatelessWidget {
final List<Invoice> invoices;
final bool isUnlocked;
final NumberFormat amountFormatter;
final DateFormat dateFormatter;
final void Function(Invoice) onTap;
final void Function(Invoice) onLongPress;
final void Function(Invoice) onEdit;
const InvoiceHistoryList({
Key? key,
required this.invoices,
required this.isUnlocked,
required this.amountFormatter,
required this.dateFormatter,
required this.onTap,
required this.onLongPress,
required this.onEdit,
}) : super(key: key);
@override
Widget build(BuildContext context) {
if (invoices.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Icon(Icons.folder_open, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text("保存された伝票がありません"),
],
),
);
}
return ListView.builder(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.only(bottom: 120), // FAB分の固定余白
itemCount: invoices.length,
itemBuilder: (context, index) {
final invoice = invoices[index];
return InvoiceHistoryItem(
invoice: invoice,
isUnlocked: isUnlocked,
amountFormatter: amountFormatter,
dateFormatter: dateFormatter,
onTap: () => onTap(invoice),
onLongPress: () => onLongPress(invoice),
onEdit: () => onEdit(invoice),
);
},
);
}
}

View file

@ -17,6 +17,7 @@ import '../main.dart'; // InvoiceFlowScreen 用
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:printing/printing.dart'; import 'package:printing/printing.dart';
import '../widgets/invoice_pdf_preview_page.dart'; import '../widgets/invoice_pdf_preview_page.dart';
import 'invoice_history/invoice_history_list.dart';
class InvoiceHistoryScreen extends StatefulWidget { class InvoiceHistoryScreen extends StatefulWidget {
const InvoiceHistoryScreen({Key? key}) : super(key: key); const InvoiceHistoryScreen({Key? key}) : super(key: key);
@ -341,112 +342,38 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
Expanded( Expanded(
child: _isLoading child: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _filteredInvoices.isEmpty : InvoiceHistoryList(
? Center( invoices: _filteredInvoices,
child: Column( isUnlocked: _isUnlocked,
mainAxisAlignment: MainAxisAlignment.center, amountFormatter: amountFormatter,
children: [ dateFormatter: dateFormatter,
const Icon(Icons.folder_open, size: 64, color: Colors.grey), onTap: (invoice) async {
const SizedBox(height: 16), await Navigator.push(
Text(_searchQuery.isEmpty ? "保存された伝票がありません" : "該当する伝票が見つかりません"), context,
], MaterialPageRoute(
), builder: (context) => InvoiceDetailPage(
) invoice: invoice,
: ListView.builder( isUnlocked: _isUnlocked,
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.only(bottom: 120), // : FAB+
itemCount: _filteredInvoices.length,
itemBuilder: (context, index) {
final invoice = _filteredInvoices[index];
return ListTile(
tileColor: invoice.isDraft ? Colors.orange.shade50 : null, //
leading: CircleAvatar(
backgroundColor: invoice.isDraft
? Colors.orange.shade100
: (_isUnlocked ? Colors.indigo.shade100 : Colors.grey.shade200),
child: Stack(
children: [
Align(
alignment: Alignment.center,
child: Icon(
invoice.isDraft ? Icons.edit_note : Icons.description_outlined,
color: invoice.isDraft
? Colors.orange
: (_isUnlocked ? Colors.indigo : Colors.grey),
),
),
if (invoice.isLocked)
const Align(alignment: Alignment.bottomRight, child: Icon(Icons.lock, size: 14, color: Colors.redAccent)),
],
),
), ),
title: Column( ),
crossAxisAlignment: CrossAxisAlignment.start, );
children: [ _loadData();
Text(invoice.customerNameForDisplay, style: TextStyle(fontWeight: FontWeight.bold, color: invoice.isLocked ? Colors.grey : Colors.black87)), },
if (invoice.subject?.isNotEmpty ?? false) onLongPress: (invoice) => _isUnlocked ? _showInvoiceActions(invoice) : _requireUnlock(),
Text( onEdit: (invoice) async {
invoice.subject!, if (invoice.isLocked || !_isUnlocked) return;
style: TextStyle(fontSize: 13, color: Colors.indigo.shade700, fontWeight: FontWeight.normal), await Navigator.push(
maxLines: 1, context,
overflow: TextOverflow.ellipsis, MaterialPageRoute(
), builder: (context) => InvoiceInputForm(
], existingInvoice: invoice,
onInvoiceGenerated: (inv, path) {},
), ),
subtitle: Text("${dateFormatter.format(invoice.date)} - ${invoice.invoiceNumber}"), ),
trailing: SizedBox( );
height: 60, _loadData();
child: Column( },
mainAxisAlignment: MainAxisAlignment.spaceEvenly, ),
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text("${amountFormatter.format(invoice.totalAmount)}",
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
if (invoice.isSynced)
const Icon(Icons.sync, size: 14, color: Colors.green)
else
const Icon(Icons.sync_disabled, size: 14, color: Colors.orange),
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(width: 32, height: 26),
icon: const Icon(Icons.edit, size: 18),
tooltip: invoice.isLocked ? "ロック中" : (_isUnlocked ? "編集" : "アンロックして編集"),
onPressed: (invoice.isLocked || !_isUnlocked)
? null
: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => InvoiceInputForm(
existingInvoice: invoice,
onInvoiceGenerated: (inv, path) {},
),
),
);
_loadData();
},
),
],
),
),
onTap: _isUnlocked
? () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => InvoiceDetailPage(
invoice: invoice,
isUnlocked: _isUnlocked, //
),
),
);
_loadData();
}
: () => _requireUnlock(),
onLongPress: _isUnlocked ? () => _showInvoiceActions(invoice) : () => _requireUnlock(),
);
},
),
), ),
], ],
), ),

View file

@ -14,6 +14,7 @@ import 'customer_master_screen.dart';
import 'product_picker_modal.dart'; import 'product_picker_modal.dart';
import '../models/company_model.dart'; import '../models/company_model.dart';
import '../services/company_repository.dart'; import '../services/company_repository.dart';
import '../widgets/keyboard_inset_wrapper.dart';
class InvoiceInputForm extends StatefulWidget { class InvoiceInputForm extends StatefulWidget {
final Function(Invoice invoice, String filePath) onInvoiceGenerated; final Function(Invoice invoice, String filePath) onInvoiceGenerated;
@ -226,7 +227,9 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
), ),
body: Stack( body: Stack(
children: [ children: [
SafeArea( KeyboardInsetWrapper(
basePadding: const EdgeInsets.fromLTRB(0, 0, 0, 0),
extraBottom: 24,
child: InteractiveViewer( child: InteractiveViewer(
panEnabled: false, panEnabled: false,
minScale: 0.8, minScale: 0.8,
@ -236,7 +239,7 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
children: [ children: [
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 140), padding: const EdgeInsets.fromLTRB(16, 16, 16, 160),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [

View file

@ -3,6 +3,7 @@ import 'package:uuid/uuid.dart';
import '../models/product_model.dart'; import '../models/product_model.dart';
import '../services/product_repository.dart'; import '../services/product_repository.dart';
import 'barcode_scanner_screen.dart'; import 'barcode_scanner_screen.dart';
import '../widgets/keyboard_inset_wrapper.dart';
class ProductMasterScreen extends StatefulWidget { class ProductMasterScreen extends StatefulWidget {
const ProductMasterScreen({Key? key}) : super(key: key); const ProductMasterScreen({Key? key}) : super(key: key);
@ -59,35 +60,39 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
builder: (context) => StatefulBuilder( builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog( builder: (context, setDialogState) => AlertDialog(
title: Text(product == null ? "商品追加" : "商品編集"), title: Text(product == null ? "商品追加" : "商品編集"),
content: SingleChildScrollView( content: KeyboardInsetWrapper(
child: Column( basePadding: EdgeInsets.zero,
mainAxisSize: MainAxisSize.min, extraBottom: 16,
children: [ child: SingleChildScrollView(
TextField(controller: nameController, decoration: const InputDecoration(labelText: "商品名")), child: Column(
TextField(controller: categoryController, decoration: const InputDecoration(labelText: "カテゴリ")), mainAxisSize: MainAxisSize.min,
TextField(controller: priceController, decoration: const InputDecoration(labelText: "初期単価"), keyboardType: TextInputType.number), children: [
TextField(controller: stockController, decoration: const InputDecoration(labelText: "在庫数"), keyboardType: TextInputType.number), TextField(controller: nameController, decoration: const InputDecoration(labelText: "商品名")),
const SizedBox(height: 8), TextField(controller: categoryController, decoration: const InputDecoration(labelText: "カテゴリ")),
Row( TextField(controller: priceController, decoration: const InputDecoration(labelText: "初期単価"), keyboardType: TextInputType.number),
children: [ TextField(controller: stockController, decoration: const InputDecoration(labelText: "在庫数"), keyboardType: TextInputType.number),
Expanded( const SizedBox(height: 8),
child: TextField(controller: barcodeController, decoration: const InputDecoration(labelText: "バーコード")), Row(
), children: [
IconButton( Expanded(
icon: const Icon(Icons.qr_code_scanner), child: TextField(controller: barcodeController, decoration: const InputDecoration(labelText: "バーコード")),
onPressed: () async { ),
final code = await Navigator.push<String>( IconButton(
context, icon: const Icon(Icons.qr_code_scanner),
MaterialPageRoute(builder: (context) => const BarcodeScannerScreen()), onPressed: () async {
); final code = await Navigator.push<String>(
if (code != null) { context,
setDialogState(() => barcodeController.text = code); MaterialPageRoute(builder: (context) => const BarcodeScannerScreen()),
} );
}, if (code != null) {
), setDialogState(() => barcodeController.text = code);
], }
), },
], ),
],
),
],
),
), ),
), ),
actions: [ actions: [
@ -150,36 +155,41 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
), ),
), ),
), ),
body: _isLoading body: KeyboardInsetWrapper(
? const Center(child: CircularProgressIndicator()) basePadding: EdgeInsets.zero,
: _filteredProducts.isEmpty extraBottom: 72,
? const Center(child: Text("商品が見つかりません")) child: _isLoading
: ListView.builder( ? const Center(child: CircularProgressIndicator())
itemCount: _filteredProducts.length, : _filteredProducts.isEmpty
itemBuilder: (context, index) { ? const Center(child: Text("商品が見つかりません"))
final p = _filteredProducts[index]; : ListView.builder(
return ListTile( padding: const EdgeInsets.only(bottom: 120, top: 8),
leading: CircleAvatar( itemCount: _filteredProducts.length,
backgroundColor: p.isLocked ? Colors.grey.shade300 : Colors.indigo.shade100, itemBuilder: (context, index) {
child: Stack( final p = _filteredProducts[index];
children: [ return ListTile(
const Align(alignment: Alignment.center, child: Icon(Icons.inventory_2, color: Colors.indigo)), leading: CircleAvatar(
if (p.isLocked) backgroundColor: p.isLocked ? Colors.grey.shade300 : Colors.indigo.shade100,
const Align(alignment: Alignment.bottomRight, child: Icon(Icons.lock, size: 14, color: Colors.redAccent)), child: Stack(
], children: [
const Align(alignment: Alignment.center, child: Icon(Icons.inventory_2, color: Colors.indigo)),
if (p.isLocked)
const Align(alignment: Alignment.bottomRight, child: Icon(Icons.lock, size: 14, color: Colors.redAccent)),
],
),
), ),
), title: Text(p.name, style: TextStyle(fontWeight: FontWeight.bold, color: p.isLocked ? Colors.grey : Colors.black87)),
title: Text(p.name, style: TextStyle(fontWeight: FontWeight.bold, color: p.isLocked ? Colors.grey : Colors.black87)), subtitle: Text("${p.category ?? '未分類'} - ¥${p.defaultUnitPrice} (在庫: ${p.stockQuantity})"),
subtitle: Text("${p.category ?? '未分類'} - ¥${p.defaultUnitPrice} (在庫: ${p.stockQuantity})"), onTap: () => _showDetailPane(p),
onTap: () => _showDetailPane(p), trailing: IconButton(
trailing: IconButton( icon: const Icon(Icons.edit),
icon: const Icon(Icons.edit), onPressed: p.isLocked ? null : () => _showEditDialog(product: p),
onPressed: p.isLocked ? null : () => _showEditDialog(product: p), tooltip: p.isLocked ? "ロック中" : "編集",
tooltip: p.isLocked ? "ロック中" : "編集", ),
), );
); },
}, ),
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
onPressed: () => _showEditDialog(), onPressed: () => _showEditDialog(),
child: const Icon(Icons.add), child: const Icon(Icons.add),

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:convert'; import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../widgets/keyboard_inset_wrapper.dart';
import 'company_info_screen.dart'; import 'company_info_screen.dart';
class SettingsScreen extends StatefulWidget { class SettingsScreen extends StatefulWidget {
@ -226,7 +227,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
return Scaffold( return Scaffold(
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
appBar: AppBar( appBar: AppBar(
@ -235,222 +235,218 @@ class _SettingsScreenState extends State<SettingsScreen> {
IconButton( IconButton(
icon: const Icon(Icons.info_outline), icon: const Icon(Icons.info_outline),
onPressed: () => _showSnackbar('設定はテンプレ実装です。実際の保存は未実装'), onPressed: () => _showSnackbar('設定はテンプレ実装です。実際の保存は未実装'),
) ),
], ],
), ),
body: SafeArea( body: KeyboardInsetWrapper(
child: AnimatedPadding( basePadding: const EdgeInsets.fromLTRB(16, 16, 16, 80),
duration: const Duration(milliseconds: 180), extraBottom: 40,
curve: Curves.easeOut, child: ListView(
padding: EdgeInsets.only(bottom: bottomInset), keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
child: ListView( children: [
padding: const EdgeInsets.all(16), _section(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, title: '自社情報',
children: [ subtitle: '会社名・住所・登録番号など',
_section( child: Column(
title: '自社情報', children: [
subtitle: '会社名・住所・登録番号など', TextField(controller: _companyNameCtrl, decoration: const InputDecoration(labelText: '会社名')),
child: Column( TextField(controller: _companyZipCtrl, decoration: const InputDecoration(labelText: '郵便番号')),
children: [ TextField(controller: _companyAddrCtrl, decoration: const InputDecoration(labelText: '住所')),
TextField(controller: _companyNameCtrl, decoration: const InputDecoration(labelText: '会社名')), TextField(controller: _companyTelCtrl, decoration: const InputDecoration(labelText: '電話番号')),
TextField(controller: _companyZipCtrl, decoration: const InputDecoration(labelText: '郵便番号')), TextField(controller: _companyFaxCtrl, decoration: const InputDecoration(labelText: 'FAX番号')),
TextField(controller: _companyAddrCtrl, decoration: const InputDecoration(labelText: '住所')), TextField(controller: _companyEmailCtrl, decoration: const InputDecoration(labelText: 'メールアドレス')),
TextField(controller: _companyTelCtrl, decoration: const InputDecoration(labelText: '電話番号')), TextField(controller: _companyUrlCtrl, decoration: const InputDecoration(labelText: 'URL')),
TextField(controller: _companyFaxCtrl, decoration: const InputDecoration(labelText: 'FAX番号')), TextField(controller: _companyRegCtrl, decoration: const InputDecoration(labelText: '登録番号 (インボイス)')),
TextField(controller: _companyEmailCtrl, decoration: const InputDecoration(labelText: 'メールアドレス')), const SizedBox(height: 8),
TextField(controller: _companyUrlCtrl, decoration: const InputDecoration(labelText: 'URL')), Row(
TextField(controller: _companyRegCtrl, decoration: const InputDecoration(labelText: '登録番号 (インボイス)')), children: [
const SizedBox(height: 8), OutlinedButton.icon(
Row( icon: const Icon(Icons.upload_file),
children: [ label: const Text('画面で編集'),
OutlinedButton.icon( onPressed: () async {
icon: const Icon(Icons.upload_file), await Navigator.push(context, MaterialPageRoute(builder: (context) => const CompanyInfoScreen()));
label: const Text('画面で編集'), },
onPressed: () async { ),
await Navigator.push(context, MaterialPageRoute(builder: (context) => const CompanyInfoScreen())); const SizedBox(width: 8),
}, ElevatedButton.icon(
), icon: const Icon(Icons.save),
const SizedBox(width: 8), label: const Text('保存'),
ElevatedButton.icon( onPressed: _saveCompany,
icon: const Icon(Icons.save), ),
label: const Text('保存'), ],
onPressed: _saveCompany, ),
), ],
],
),
],
),
), ),
_section( ),
title: '担当者情報', _section(
subtitle: '署名や連絡先(送信者情報)', title: '担当者情報',
child: Column( subtitle: '署名や連絡先(送信者情報)',
children: [ child: Column(
TextField(controller: _staffNameCtrl, decoration: const InputDecoration(labelText: '担当者名')), children: [
TextField(controller: _staffMailCtrl, decoration: const InputDecoration(labelText: 'メールアドレス')), TextField(controller: _staffNameCtrl, decoration: const InputDecoration(labelText: '担当者名')),
const SizedBox(height: 8), TextField(controller: _staffMailCtrl, decoration: const InputDecoration(labelText: 'メールアドレス')),
ElevatedButton.icon( const SizedBox(height: 8),
icon: const Icon(Icons.save), ElevatedButton.icon(
label: const Text('保存'), icon: const Icon(Icons.save),
onPressed: _saveStaff, label: const Text('保存'),
), onPressed: _saveStaff,
], ),
), ],
), ),
_section( ),
title: 'SMTP情報', _section(
subtitle: 'メール送信サーバ設定(テンプレ)', title: 'SMTP情報',
child: Column( subtitle: 'メール送信サーバ設定(テンプレ)',
children: [ child: Column(
TextField(controller: _smtpHostCtrl, decoration: const InputDecoration(labelText: 'ホスト名')), children: [
TextField(controller: _smtpPortCtrl, decoration: const InputDecoration(labelText: 'ポート番号'), keyboardType: TextInputType.number), TextField(controller: _smtpHostCtrl, decoration: const InputDecoration(labelText: 'ホスト名')),
TextField(controller: _smtpUserCtrl, decoration: const InputDecoration(labelText: 'ユーザー名')), TextField(controller: _smtpPortCtrl, decoration: const InputDecoration(labelText: 'ポート番号'), keyboardType: TextInputType.number),
TextField(controller: _smtpPassCtrl, decoration: const InputDecoration(labelText: 'パスワード'), obscureText: true), TextField(controller: _smtpUserCtrl, decoration: const InputDecoration(labelText: 'ユーザー名')),
TextField(controller: _smtpBccCtrl, decoration: const InputDecoration(labelText: 'BCC (カンマ区切り可)')), TextField(controller: _smtpPassCtrl, decoration: const InputDecoration(labelText: 'パスワード'), obscureText: true),
SwitchListTile( TextField(controller: _smtpBccCtrl, decoration: const InputDecoration(labelText: 'BCC (カンマ区切り可)')),
title: const Text('STARTTLS を使用'), SwitchListTile(
value: _smtpTls, title: const Text('STARTTLS を使用'),
onChanged: (v) => setState(() => _smtpTls = v), value: _smtpTls,
), onChanged: (v) => setState(() => _smtpTls = v),
ElevatedButton.icon( ),
icon: const Icon(Icons.save), ElevatedButton.icon(
label: const Text('保存'), icon: const Icon(Icons.save),
onPressed: _saveSmtp, label: const Text('保存'),
), onPressed: _saveSmtp,
], ),
), ],
), ),
_section( ),
title: '外部同期(母艦システム「お局様」連携)', _section(
subtitle: '実行ボタンなし。ホストドメインとパスワードを入力してください。', title: '外部同期(母艦システム「お局様」連携)',
child: Column( subtitle: '実行ボタンなし。ホストドメインとパスワードを入力してください。',
children: [ child: Column(
TextField(controller: _externalHostCtrl, decoration: const InputDecoration(labelText: 'ホストドメイン')), children: [
TextField(controller: _externalPassCtrl, decoration: const InputDecoration(labelText: 'パスワード'), obscureText: true), TextField(controller: _externalHostCtrl, decoration: const InputDecoration(labelText: 'ホストドメイン')),
const SizedBox(height: 8), TextField(controller: _externalPassCtrl, decoration: const InputDecoration(labelText: 'パスワード'), obscureText: true),
ElevatedButton.icon( const SizedBox(height: 8),
icon: const Icon(Icons.save), ElevatedButton.icon(
label: const Text('保存'), icon: const Icon(Icons.save),
onPressed: _saveExternalSync, label: const Text('保存'),
), onPressed: _saveExternalSync,
], ),
), ],
), ),
_section( ),
title: 'バックアップドライブ', _section(
subtitle: 'バックアップ先のクラウド/ローカル', title: 'バックアップドライブ',
child: Column( subtitle: 'バックアップ先のクラウド/ローカル',
children: [ child: Column(
TextField(controller: _backupPathCtrl, decoration: const InputDecoration(labelText: '保存先パス/URL')), children: [
const SizedBox(height: 8), TextField(controller: _backupPathCtrl, decoration: const InputDecoration(labelText: '保存先パス/URL')),
Row( const SizedBox(height: 8),
children: [ Row(
OutlinedButton.icon( children: [
icon: const Icon(Icons.folder_open), OutlinedButton.icon(
label: const Text('参照'), icon: const Icon(Icons.folder_open),
onPressed: _pickBackupPath, label: const Text('参照'),
onPressed: _pickBackupPath,
),
const SizedBox(width: 8),
ElevatedButton.icon(
icon: const Icon(Icons.save),
label: const Text('保存'),
onPressed: _saveBackup,
),
],
),
],
),
),
_section(
title: 'テーマ選択',
subtitle: '配色や見た目を切り替え(テンプレ)',
child: Column(
children: [
RadioListTile<String>(
value: 'light',
groupValue: _theme,
title: const Text('ライト'),
onChanged: (v) => setState(() => _theme = v ?? 'light'),
),
RadioListTile<String>(
value: 'dark',
groupValue: _theme,
title: const Text('ダーク'),
onChanged: (v) => setState(() => _theme = v ?? 'dark'),
),
RadioListTile<String>(
value: 'system',
groupValue: _theme,
title: const Text('システムに従う'),
onChanged: (v) => setState(() => _theme = v ?? 'system'),
),
const SizedBox(height: 8),
ElevatedButton.icon(
icon: const Icon(Icons.save),
label: const Text('保存'),
onPressed: () => _showSnackbar('テーマ設定を保存(テンプレ): $_theme'),
),
],
),
),
_section(
title: 'かなインデックス追加',
subtitle: '漢字→行1文字ずつを追加して索引を補強',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: TextField(
controller: _kanaKeyCtrl,
maxLength: 1,
decoration: const InputDecoration(labelText: '漢字1文字', counterText: ''),
), ),
const SizedBox(width: 8), ),
ElevatedButton.icon( const SizedBox(width: 8),
icon: const Icon(Icons.save), Expanded(
label: const Text('保存'), child: TextField(
onPressed: _saveBackup, controller: _kanaValCtrl,
maxLength: 1,
decoration: const InputDecoration(labelText: '行(例: さ)', counterText: ''),
), ),
], ),
), const SizedBox(width: 8),
], ElevatedButton(
), onPressed: () {
final k = _kanaKeyCtrl.text.trim();
final v = _kanaValCtrl.text.trim();
if (k.isEmpty || v.isEmpty) return;
setState(() {
_customKanaMap[k] = v;
});
},
child: const Text('追加'),
),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 6,
children: _customKanaMap.entries
.map((e) => Chip(
label: Text('${e.key}: ${e.value}'),
onDeleted: () => setState(() => _customKanaMap.remove(e.key)),
))
.toList(),
),
const SizedBox(height: 8),
ElevatedButton.icon(
icon: const Icon(Icons.save),
label: const Text('保存'),
onPressed: _saveKanaMap,
),
],
), ),
_section( ),
title: 'テーマ選択', ],
subtitle: '配色や見た目を切り替え(テンプレ)',
child: Column(
children: [
RadioListTile<String>(
value: 'light',
groupValue: _theme,
title: const Text('ライト'),
onChanged: (v) => setState(() => _theme = v ?? 'light'),
),
RadioListTile<String>(
value: 'dark',
groupValue: _theme,
title: const Text('ダーク'),
onChanged: (v) => setState(() => _theme = v ?? 'dark'),
),
RadioListTile<String>(
value: 'system',
groupValue: _theme,
title: const Text('システムに従う'),
onChanged: (v) => setState(() => _theme = v ?? 'system'),
),
const SizedBox(height: 8),
ElevatedButton.icon(
icon: const Icon(Icons.save),
label: const Text('保存'),
onPressed: () => _showSnackbar('テーマ設定を保存(テンプレ): $_theme'),
),
],
),
),
_section(
title: 'かなインデックス追加',
subtitle: '漢字→行1文字ずつを追加して索引を補強',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: TextField(
controller: _kanaKeyCtrl,
maxLength: 1,
decoration: const InputDecoration(labelText: '漢字1文字', counterText: ''),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _kanaValCtrl,
maxLength: 1,
decoration: const InputDecoration(labelText: '行(例: さ)', counterText: ''),
),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () {
final k = _kanaKeyCtrl.text.trim();
final v = _kanaValCtrl.text.trim();
if (k.isEmpty || v.isEmpty) return;
setState(() {
_customKanaMap[k] = v;
});
},
child: const Text('追加'),
),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 6,
children: _customKanaMap.entries
.map((e) => Chip(
label: Text('${e.key}: ${e.value}'),
onDeleted: () => setState(() => _customKanaMap.remove(e.key)),
))
.toList(),
),
const SizedBox(height: 8),
ElevatedButton.icon(
icon: const Icon(Icons.save),
label: const Text('保存'),
onPressed: _saveKanaMap,
),
],
),
),
],
),
), ),
), ),
); );

View file

@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
/// Wraps content with SafeArea and animated bottom padding based on keyboard.
/// Use this to keep forms scrollable without Scaffold resizing.
class KeyboardInsetWrapper extends StatelessWidget {
final Widget child;
final EdgeInsets basePadding;
final double extraBottom;
final Duration duration;
final Curve curve;
const KeyboardInsetWrapper({
Key? key,
required this.child,
this.basePadding = EdgeInsets.zero,
this.extraBottom = 0,
this.duration = const Duration(milliseconds: 180),
this.curve = Curves.easeOut,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
return SafeArea(
child: AnimatedPadding(
duration: duration,
curve: curve,
padding: basePadding.add(EdgeInsets.only(bottom: bottomInset + extraBottom)),
child: child,
),
);
}
}