diff --git a/lib/screens/company_info_screen.dart b/lib/screens/company_info_screen.dart index b147a98..2b37f53 100644 --- a/lib/screens/company_info_screen.dart +++ b/lib/screens/company_info_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import '../models/company_model.dart'; import '../services/company_repository.dart'; +import '../widgets/keyboard_inset_wrapper.dart'; class CompanyInfoScreen extends StatefulWidget { const CompanyInfoScreen({Key? key}) : super(key: key); @@ -76,70 +77,74 @@ class _CompanyInfoScreenState extends State { IconButton(icon: const Icon(Icons.check), onPressed: _save), ], ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildTextField("自社名", _nameController), - const SizedBox(height: 12), - _buildTextField("郵便番号", _zipController), - const SizedBox(height: 12), - _buildTextField("住所", _addressController), - const SizedBox(height: 12), - _buildTextField("電話番号", _telController), - const SizedBox(height: 20), - const Text("デフォルト消費税率", style: TextStyle(fontWeight: FontWeight.bold)), - Row( - children: [ - ChoiceChip(label: const Text("10%"), selected: _taxRate == 0.10, onSelected: (_) => setState(() => _taxRate = 0.10)), - const SizedBox(width: 8), - ChoiceChip(label: const Text("8%"), selected: _taxRate == 0.08, onSelected: (_) => setState(() => _taxRate = 0.08)), - ], - ), - const SizedBox(height: 20), - 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)), + body: KeyboardInsetWrapper( + basePadding: const EdgeInsets.all(16), + extraBottom: 32, + child: SingleChildScrollView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTextField("自社名", _nameController), + const SizedBox(height: 12), + _buildTextField("郵便番号", _zipController), + const SizedBox(height: 12), + _buildTextField("住所", _addressController), + const SizedBox(height: 12), + _buildTextField("電話番号", _telController), + const SizedBox(height: 20), + const Text("デフォルト消費税率", style: TextStyle(fontWeight: FontWeight.bold)), + Row( + children: [ + ChoiceChip(label: const Text("10%"), selected: _taxRate == 0.10, onSelected: (_) => setState(() => _taxRate = 0.10)), + const SizedBox(width: 8), + ChoiceChip(label: const Text("8%"), selected: _taxRate == 0.08, onSelected: (_) => setState(() => _taxRate = 0.08)), + ], ), - ), - const SizedBox(height: 8), - const Text("白い紙に押した判子を真上から撮影してください", style: TextStyle(fontSize: 12, color: Colors.grey)), - ], + const SizedBox(height: 20), + 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: 8), + const Text("白い紙に押した判子を真上から撮影してください", style: TextStyle(fontSize: 12, color: Colors.grey)), + ], + ), ), ), ); diff --git a/lib/screens/customer_master_screen.dart b/lib/screens/customer_master_screen.dart index ddaa774..25320b2 100644 --- a/lib/screens/customer_master_screen.dart +++ b/lib/screens/customer_master_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:uuid/uuid.dart'; import 'package:flutter_contacts/flutter_contacts.dart'; +import '../widgets/keyboard_inset_wrapper.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'dart:convert'; import '../models/customer_model.dart'; @@ -315,141 +316,148 @@ class _CustomerMasterScreenState extends State { final result = await showDialog( context: context, builder: (context) => StatefulBuilder( - builder: (context, setDialogState) => AlertDialog( - title: Text(isEdit ? "顧客を編集" : "顧客を新規登録"), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: displayNameController, - decoration: const InputDecoration(labelText: "表示名(略称)", hintText: "例: 佐々木製作所"), - onChanged: (v) { - 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( + builder: (context, setDialogState) { + return AlertDialog( + title: Text(isEdit ? "顧客を編集" : "顧客を新規登録"), + content: KeyboardInsetWrapper( + basePadding: const EdgeInsets.fromLTRB(0, 0, 0, 12), + extraBottom: 20, + child: SingleChildScrollView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Expanded( - child: RadioListTile( - dense: true, - title: const Text('会社'), - value: true, - groupValue: isCompany, - onChanged: (v) { - setDialogState(() { - isCompany = v ?? true; - selectedTitle = '御中'; - }); - }, + TextField( + controller: displayNameController, + decoration: const InputDecoration(labelText: "表示名(略称)", hintText: "例: 佐々木製作所"), + onChanged: (v) { + 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, ), ), - Expanded( - child: RadioListTile( - dense: true, - title: const Text('個人'), - value: false, - groupValue: isCompany, - onChanged: (v) { - setDialogState(() { - isCompany = v ?? false; - selectedTitle = '様'; - }); - }, - ), + Row( + children: [ + Expanded( + child: RadioListTile( + dense: true, + title: const Text('会社'), + value: true, + groupValue: isCompany, + onChanged: (v) { + setDialogState(() { + isCompany = v ?? true; + selectedTitle = '御中'; + }); + }, + ), + ), + Expanded( + child: RadioListTile( + dense: true, + title: const Text('個人'), + value: false, + groupValue: isCompany, + onChanged: (v) { + setDialogState(() { + isCompany = v ?? false; + selectedTitle = '様'; + }); + }, + ), + ), + ], + ), + DropdownButtonFormField( + 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( - 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: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")), - TextButton( - onPressed: () { - if (displayNameController.text.isEmpty || formalNameController.text.isEmpty) { - return; - } - final head1 = _normalizeIndexChar(head1Controller.text); - final head2 = _normalizeIndexChar(head2Controller.text); - final newCustomer = Customer( - id: customer?.id ?? const Uuid().v4(), - displayName: displayNameController.text, - formalName: formalNameController.text, - title: selectedTitle, - department: departmentController.text.isEmpty ? null : departmentController.text, - address: addressController.text.isEmpty ? null : addressController.text, - tel: telController.text.isEmpty ? null : telController.text, - headChar1: head1.isEmpty ? _headKana(displayNameController.text) : head1, - headChar2: head2.isEmpty ? null : head2, - isLocked: customer?.isLocked ?? false, - ); - Navigator.pop(context, newCustomer); - }, - child: const Text("保存"), - ), - ], - ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")), + TextButton( + onPressed: () { + if (displayNameController.text.isEmpty || formalNameController.text.isEmpty) { + return; + } + final head1 = _normalizeIndexChar(head1Controller.text); + final head2 = _normalizeIndexChar(head2Controller.text); + final newCustomer = Customer( + id: customer?.id ?? const Uuid().v4(), + displayName: displayNameController.text, + formalName: formalNameController.text, + title: selectedTitle, + department: departmentController.text.isEmpty ? null : departmentController.text, + address: addressController.text.isEmpty ? null : addressController.text, + tel: telController.text.isEmpty ? null : telController.text, + headChar1: head1.isEmpty ? _headKana(displayNameController.text) : head1, + headChar2: head2.isEmpty ? null : head2, + isLocked: customer?.isLocked ?? false, + ); + Navigator.pop(context, newCustomer); + }, + child: const Text("保存"), + ), + ], + ); + }, ), ); @@ -741,76 +749,80 @@ class _CustomerMasterScreenState extends State { ), ], ), - body: Column( - children: [ - Padding( - padding: const EdgeInsets.all(12), - child: TextField( - 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) + body: KeyboardInsetWrapper( + basePadding: const EdgeInsets.fromLTRB(0, 8, 0, 80), + extraBottom: 40, + child: Column( + children: [ Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: SwitchListTile( - title: const Text('株式会社/有限会社などの接頭辞を無視してソート'), - value: _ignoreCorpPrefix, - onChanged: (v) => setState(() { - _ignoreCorpPrefix = v; - _applyFilter(); - }), + padding: const EdgeInsets.all(12), + child: TextField( + 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), ), ), - Expanded( - child: _isLoading - ? const Center(child: CircularProgressIndicator()) - : _filtered.isEmpty - ? const Center(child: Text("顧客が登録されていません")) - : ListView.builder( - 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)), - ], + if (!widget.selectionMode) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: SwitchListTile( + title: const Text('株式会社/有限会社などの接頭辞を無視してソート'), + value: _ignoreCorpPrefix, + onChanged: (v) => setState(() { + _ignoreCorpPrefix = v; + _applyFilter(); + }), + ), + ), + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _filtered.isEmpty + ? 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)), - subtitle: Text("${c.formalName} ${c.title}"), - onTap: widget.selectionMode ? () => Navigator.pop(context, c) : () => _showDetailPane(c), - trailing: widget.selectionMode - ? null - : IconButton( - icon: const Icon(Icons.edit), - onPressed: c.isLocked ? null : () => _addOrEditCustomer(customer: c), - tooltip: c.isLocked ? "ロック中" : "編集", - ), - onLongPress: () => _showContextActions(c), - ); - }, - ), - ), - ], + title: Text(c.displayName, style: TextStyle(fontWeight: FontWeight.bold, color: c.isLocked ? Colors.grey : Colors.black87)), + subtitle: Text("${c.formalName} ${c.title}"), + onTap: widget.selectionMode ? () => Navigator.pop(context, c) : () => _showDetailPane(c), + trailing: widget.selectionMode + ? null + : IconButton( + icon: const Icon(Icons.edit), + onPressed: c.isLocked ? null : () => _addOrEditCustomer(customer: c), + tooltip: c.isLocked ? "ロック中" : "編集", + ), + onLongPress: () => _showContextActions(c), + ); + }, + ), + ), + ], + ), ), floatingActionButton: FloatingActionButton.extended( onPressed: _showAddMenu, icon: const Icon(Icons.add), - label: const Text('顧客を追加'), + label: Text(widget.selectionMode ? "選択" : "追加"), backgroundColor: Colors.indigo, foregroundColor: Colors.white, ), @@ -963,7 +975,7 @@ class _CustomerMasterScreenState extends State { OutlinedButton.icon( onPressed: () { Navigator.pop(context); - _showContactUpdateDialog(c); + _showContactUpdateSheet(c); }, icon: const Icon(Icons.contact_mail), label: const Text("連絡先を更新"), @@ -1006,4 +1018,38 @@ class _CustomerMasterScreenState extends State { ), ); } + + 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(); + }, + ), + ], + ), + ), + ), + ); + } } diff --git a/lib/screens/customer_picker_modal.dart b/lib/screens/customer_picker_modal.dart index a06a96f..68b0af0 100644 --- a/lib/screens/customer_picker_modal.dart +++ b/lib/screens/customer_picker_modal.dart @@ -3,6 +3,7 @@ import 'package:flutter_contacts/flutter_contacts.dart'; import 'package:uuid/uuid.dart'; import '../models/customer_model.dart'; import '../services/customer_repository.dart'; +import '../widgets/keyboard_inset_wrapper.dart'; /// 顧客マスターからの選択、登録、編集、削除を行うモーダル class CustomerPickerModal extends StatefulWidget { @@ -203,81 +204,87 @@ class _CustomerPickerModalState extends State { @override Widget build(BuildContext context) { return Material( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text("顧客マスター管理", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), - IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)), - ], - ), - const SizedBox(height: 12), - TextField( - decoration: InputDecoration( - hintText: "登録済み顧客を検索...", - prefixIcon: const Icon(Icons.search), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + child: KeyboardInsetWrapper( + basePadding: const EdgeInsets.fromLTRB(0, 0, 0, 24), + extraBottom: 24, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text("顧客マスター管理", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)), + ], ), - onChanged: _onSearch, - ), - 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 SizedBox(height: 12), + TextField( + decoration: InputDecoration( + hintText: "登録済み顧客を検索...", + prefixIcon: const Icon(Icons.search), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + ), + onChanged: _onSearch, ), - ), - ], + 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(), - Expanded( - child: _isLoading - ? const Center(child: CircularProgressIndicator()) - : _filteredCustomers.isEmpty - ? const Center(child: Text("該当する顧客がいません")) - : ListView.builder( - itemCount: _filteredCustomers.length, - itemBuilder: (context, index) { - final customer = _filteredCustomers[index]; - return ListTile( - leading: const CircleAvatar(child: Icon(Icons.business)), - title: Text(customer.formalName), - subtitle: Text(customer.department?.isNotEmpty == true ? customer.department! : "部署未設定"), - onTap: () => widget.onCustomerSelected(customer), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.edit, color: Colors.blueGrey, size: 20), - onPressed: () => _showCustomerEditDialog( - displayName: customer.displayName, - initialFormalName: customer.formalName, - existingCustomer: customer, + 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), + itemCount: _filteredCustomers.length, + itemBuilder: (context, index) { + final customer = _filteredCustomers[index]; + return ListTile( + leading: const CircleAvatar(child: Icon(Icons.business)), + title: Text(customer.formalName), + subtitle: Text(customer.department?.isNotEmpty == true ? customer.department! : "部署未設定"), + onTap: () => widget.onCustomerSelected(customer), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit, color: Colors.blueGrey, size: 20), + onPressed: () => _showCustomerEditDialog( + displayName: customer.displayName, + initialFormalName: customer.formalName, + existingCustomer: customer, + ), ), - ), - IconButton( - icon: const Icon(Icons.delete_outline, color: Colors.redAccent, size: 20), - onPressed: () => _confirmDelete(customer), - ), - ], - ), - ); - }, - ), - ), - ], + IconButton( + icon: const Icon(Icons.delete_outline, color: Colors.redAccent, size: 20), + onPressed: () => _confirmDelete(customer), + ), + ], + ), + ); + }, + ), + ), + ], + ), ), ); } diff --git a/lib/screens/invoice_detail_page.dart b/lib/screens/invoice_detail_page.dart index 83c76aa..fb8ed4f 100644 --- a/lib/screens/invoice_detail_page.dart +++ b/lib/screens/invoice_detail_page.dart @@ -13,6 +13,7 @@ import '../services/customer_repository.dart'; import '../services/company_repository.dart'; import 'product_picker_modal.dart'; import '../models/company_model.dart'; +import '../widgets/keyboard_inset_wrapper.dart'; class InvoiceDetailPage extends StatefulWidget { final Invoice invoice; @@ -158,6 +159,7 @@ class _InvoiceDetailPageState extends State { return Scaffold( backgroundColor: themeColor, + resizeToAvoidBottomInset: false, appBar: AppBar( leading: const BackButton(), // 常に表示 title: Row( @@ -245,81 +247,84 @@ class _InvoiceDetailPageState extends State { ] ], ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (isDraft) - Container( - width: double.infinity, - padding: const EdgeInsets.all(8), - margin: const EdgeInsets.only(bottom: 8), - decoration: BoxDecoration( - color: Colors.orange.shade50, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.orange.shade200), - ), - child: Row( - children: const [ - Icon(Icons.edit_note, color: Colors.orange), - SizedBox(width: 8), - Expanded( - child: Text( - "下書き: 未確定・PDFは正式発行で確定", - style: TextStyle(color: Colors.orange), + body: KeyboardInsetWrapper( + basePadding: const EdgeInsets.all(16.0), + extraBottom: 48, + child: SingleChildScrollView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isDraft) + Container( + width: double.infinity, + padding: const EdgeInsets.all(8), + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: Colors.orange.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.orange.shade200), + ), + child: Row( + children: const [ + Icon(Icons.edit_note, color: Colors.orange), + SizedBox(width: 8), + Expanded( + child: Text( + "下書き: 未確定・PDFは正式発行で確定", + style: TextStyle(color: Colors.orange), + ), ), - ), - ], + ], + ), ), - ), - _buildHeaderSection(textColor), - if (_isEditing) ...[ - const SizedBox(height: 16), - _buildDraftToggleEdit(), // 編集用トグル - const SizedBox(height: 16), - _buildExperimentalSection(isDraft), + _buildHeaderSection(textColor), + if (_isEditing) ...[ + const SizedBox(height: 16), + _buildDraftToggleEdit(), // 編集用トグル + const SizedBox(height: 16), + _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) { - final dateFormatter = DateFormat('yyyy年MM月dd日'); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -365,7 +370,7 @@ class _InvoiceDetailPageState extends State { style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: textColor)), if (_currentInvoice.subject?.isNotEmpty ?? false) ...[ const SizedBox(height: 8), - Text("件名: ${_currentInvoice.subject}", + Text("件名: ${_currentInvoice.subject}", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.indigoAccent)), ], if (_currentInvoice.customer.department != null && _currentInvoice.customer.department!.isNotEmpty) diff --git a/lib/screens/invoice_history/invoice_history_item.dart b/lib/screens/invoice_history/invoice_history_item.dart new file mode 100644 index 0000000..26c27c3 --- /dev/null +++ b/lib/screens/invoice_history/invoice_history_item.dart @@ -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, + ); + } +} diff --git a/lib/screens/invoice_history/invoice_history_list.dart b/lib/screens/invoice_history/invoice_history_list.dart new file mode 100644 index 0000000..9c6a6dc --- /dev/null +++ b/lib/screens/invoice_history/invoice_history_list.dart @@ -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 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), + ); + }, + ); + } +} diff --git a/lib/screens/invoice_history_screen.dart b/lib/screens/invoice_history_screen.dart index 7bf0b94..f607630 100644 --- a/lib/screens/invoice_history_screen.dart +++ b/lib/screens/invoice_history_screen.dart @@ -17,6 +17,7 @@ import '../main.dart'; // InvoiceFlowScreen 用 import 'package:package_info_plus/package_info_plus.dart'; import 'package:printing/printing.dart'; import '../widgets/invoice_pdf_preview_page.dart'; +import 'invoice_history/invoice_history_list.dart'; class InvoiceHistoryScreen extends StatefulWidget { const InvoiceHistoryScreen({Key? key}) : super(key: key); @@ -341,112 +342,38 @@ class _InvoiceHistoryScreenState extends State { Expanded( child: _isLoading ? const Center(child: CircularProgressIndicator()) - : _filteredInvoices.isEmpty - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.folder_open, size: 64, color: Colors.grey), - const SizedBox(height: 16), - Text(_searchQuery.isEmpty ? "保存された伝票がありません" : "該当する伝票が見つかりません"), - ], - ), - ) - : ListView.builder( - 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)), - ], - ), + : InvoiceHistoryList( + invoices: _filteredInvoices, + isUnlocked: _isUnlocked, + amountFormatter: amountFormatter, + dateFormatter: dateFormatter, + onTap: (invoice) async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => InvoiceDetailPage( + invoice: invoice, + isUnlocked: _isUnlocked, ), - 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, - ), - ], + ), + ); + _loadData(); + }, + onLongPress: (invoice) => _isUnlocked ? _showInvoiceActions(invoice) : _requireUnlock(), + onEdit: (invoice) async { + if (invoice.isLocked || !_isUnlocked) return; + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => InvoiceInputForm( + existingInvoice: invoice, + onInvoiceGenerated: (inv, path) {}, ), - subtitle: Text("${dateFormatter.format(invoice.date)} - ${invoice.invoiceNumber}"), - trailing: SizedBox( - height: 60, - 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(), - ); - }, - ), + ), + ); + _loadData(); + }, + ), ), ], ), diff --git a/lib/screens/invoice_input_screen.dart b/lib/screens/invoice_input_screen.dart index 44bb835..ad800ef 100644 --- a/lib/screens/invoice_input_screen.dart +++ b/lib/screens/invoice_input_screen.dart @@ -14,6 +14,7 @@ import 'customer_master_screen.dart'; import 'product_picker_modal.dart'; import '../models/company_model.dart'; import '../services/company_repository.dart'; +import '../widgets/keyboard_inset_wrapper.dart'; class InvoiceInputForm extends StatefulWidget { final Function(Invoice invoice, String filePath) onInvoiceGenerated; @@ -226,7 +227,9 @@ class _InvoiceInputFormState extends State { ), body: Stack( children: [ - SafeArea( + KeyboardInsetWrapper( + basePadding: const EdgeInsets.fromLTRB(0, 0, 0, 0), + extraBottom: 24, child: InteractiveViewer( panEnabled: false, minScale: 0.8, @@ -236,7 +239,7 @@ class _InvoiceInputFormState extends State { children: [ Expanded( child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 140), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 160), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/screens/product_master_screen.dart b/lib/screens/product_master_screen.dart index 1419e59..ab2f2ac 100644 --- a/lib/screens/product_master_screen.dart +++ b/lib/screens/product_master_screen.dart @@ -3,6 +3,7 @@ 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({Key? key}) : super(key: key); @@ -59,35 +60,39 @@ class _ProductMasterScreenState extends State { builder: (context) => StatefulBuilder( builder: (context, setDialogState) => AlertDialog( title: Text(product == null ? "商品追加" : "商品編集"), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField(controller: nameController, decoration: const InputDecoration(labelText: "商品名")), - TextField(controller: categoryController, decoration: const InputDecoration(labelText: "カテゴリ")), - TextField(controller: priceController, decoration: const InputDecoration(labelText: "初期単価"), keyboardType: TextInputType.number), - TextField(controller: stockController, decoration: const InputDecoration(labelText: "在庫数"), keyboardType: TextInputType.number), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: TextField(controller: barcodeController, decoration: const InputDecoration(labelText: "バーコード")), - ), - IconButton( - icon: const Icon(Icons.qr_code_scanner), - onPressed: () async { - final code = await Navigator.push( - context, - MaterialPageRoute(builder: (context) => const BarcodeScannerScreen()), - ); - if (code != null) { - setDialogState(() => barcodeController.text = code); - } - }, - ), - ], - ), - ], + content: KeyboardInsetWrapper( + basePadding: EdgeInsets.zero, + extraBottom: 16, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField(controller: nameController, decoration: const InputDecoration(labelText: "商品名")), + TextField(controller: categoryController, decoration: const InputDecoration(labelText: "カテゴリ")), + TextField(controller: priceController, decoration: const InputDecoration(labelText: "初期単価"), keyboardType: TextInputType.number), + TextField(controller: stockController, decoration: const InputDecoration(labelText: "在庫数"), keyboardType: TextInputType.number), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextField(controller: barcodeController, decoration: const InputDecoration(labelText: "バーコード")), + ), + IconButton( + icon: const Icon(Icons.qr_code_scanner), + onPressed: () async { + final code = await Navigator.push( + context, + MaterialPageRoute(builder: (context) => const BarcodeScannerScreen()), + ); + if (code != null) { + setDialogState(() => barcodeController.text = code); + } + }, + ), + ], + ), + ], + ), ), ), actions: [ @@ -150,36 +155,41 @@ class _ProductMasterScreenState extends State { ), ), ), - body: _isLoading - ? const Center(child: CircularProgressIndicator()) - : _filteredProducts.isEmpty - ? const Center(child: Text("商品が見つかりません")) - : ListView.builder( - itemCount: _filteredProducts.length, - itemBuilder: (context, index) { - final p = _filteredProducts[index]; - return ListTile( - leading: CircleAvatar( - backgroundColor: p.isLocked ? Colors.grey.shade300 : Colors.indigo.shade100, - 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)), - ], + body: KeyboardInsetWrapper( + basePadding: EdgeInsets.zero, + extraBottom: 72, + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _filteredProducts.isEmpty + ? const Center(child: Text("商品が見つかりません")) + : ListView.builder( + padding: const EdgeInsets.only(bottom: 120, top: 8), + itemCount: _filteredProducts.length, + itemBuilder: (context, index) { + final p = _filteredProducts[index]; + return ListTile( + leading: CircleAvatar( + backgroundColor: p.isLocked ? Colors.grey.shade300 : Colors.indigo.shade100, + 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)), - subtitle: Text("${p.category ?? '未分類'} - ¥${p.defaultUnitPrice} (在庫: ${p.stockQuantity})"), - onTap: () => _showDetailPane(p), - trailing: IconButton( - icon: const Icon(Icons.edit), - onPressed: p.isLocked ? null : () => _showEditDialog(product: p), - tooltip: p.isLocked ? "ロック中" : "編集", - ), - ); - }, - ), + 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( + icon: const Icon(Icons.edit), + onPressed: p.isLocked ? null : () => _showEditDialog(product: p), + tooltip: p.isLocked ? "ロック中" : "編集", + ), + ); + }, + ), + ), floatingActionButton: FloatingActionButton( onPressed: () => _showEditDialog(), child: const Icon(Icons.add), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 12e856b..8b4a89e 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1,6 +1,7 @@ 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 { @@ -226,7 +227,6 @@ class _SettingsScreenState extends State { @override Widget build(BuildContext context) { - final bottomInset = MediaQuery.of(context).viewInsets.bottom; return Scaffold( resizeToAvoidBottomInset: false, appBar: AppBar( @@ -235,222 +235,218 @@ class _SettingsScreenState extends State { IconButton( icon: const Icon(Icons.info_outline), onPressed: () => _showSnackbar('設定はテンプレ実装です。実際の保存は未実装'), - ) + ), ], ), - body: SafeArea( - child: AnimatedPadding( - duration: const Duration(milliseconds: 180), - curve: Curves.easeOut, - padding: EdgeInsets.only(bottom: bottomInset), - child: ListView( - padding: const EdgeInsets.all(16), - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - children: [ - _section( - title: '自社情報', - subtitle: '会社名・住所・登録番号など', - child: Column( - children: [ - TextField(controller: _companyNameCtrl, decoration: const InputDecoration(labelText: '会社名')), - TextField(controller: _companyZipCtrl, decoration: const InputDecoration(labelText: '郵便番号')), - TextField(controller: _companyAddrCtrl, decoration: const InputDecoration(labelText: '住所')), - TextField(controller: _companyTelCtrl, decoration: const InputDecoration(labelText: '電話番号')), - TextField(controller: _companyFaxCtrl, decoration: const InputDecoration(labelText: 'FAX番号')), - TextField(controller: _companyEmailCtrl, decoration: const InputDecoration(labelText: 'メールアドレス')), - TextField(controller: _companyUrlCtrl, decoration: const InputDecoration(labelText: 'URL')), - TextField(controller: _companyRegCtrl, decoration: const InputDecoration(labelText: '登録番号 (インボイス)')), - const SizedBox(height: 8), - Row( - children: [ - OutlinedButton.icon( - icon: const Icon(Icons.upload_file), - 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), - label: const Text('保存'), - onPressed: _saveCompany, - ), - ], - ), - ], - ), + body: KeyboardInsetWrapper( + basePadding: const EdgeInsets.fromLTRB(16, 16, 16, 80), + extraBottom: 40, + child: ListView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + children: [ + _section( + title: '自社情報', + subtitle: '会社名・住所・登録番号など', + child: Column( + children: [ + TextField(controller: _companyNameCtrl, decoration: const InputDecoration(labelText: '会社名')), + TextField(controller: _companyZipCtrl, decoration: const InputDecoration(labelText: '郵便番号')), + TextField(controller: _companyAddrCtrl, decoration: const InputDecoration(labelText: '住所')), + TextField(controller: _companyTelCtrl, decoration: const InputDecoration(labelText: '電話番号')), + TextField(controller: _companyFaxCtrl, decoration: const InputDecoration(labelText: 'FAX番号')), + TextField(controller: _companyEmailCtrl, decoration: const InputDecoration(labelText: 'メールアドレス')), + TextField(controller: _companyUrlCtrl, decoration: const InputDecoration(labelText: 'URL')), + TextField(controller: _companyRegCtrl, decoration: const InputDecoration(labelText: '登録番号 (インボイス)')), + const SizedBox(height: 8), + Row( + children: [ + OutlinedButton.icon( + icon: const Icon(Icons.upload_file), + 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), + label: const Text('保存'), + onPressed: _saveCompany, + ), + ], + ), + ], ), - _section( - title: '担当者情報', - subtitle: '署名や連絡先(送信者情報)', - child: Column( - children: [ - TextField(controller: _staffNameCtrl, decoration: const InputDecoration(labelText: '担当者名')), - TextField(controller: _staffMailCtrl, decoration: const InputDecoration(labelText: 'メールアドレス')), - const SizedBox(height: 8), - ElevatedButton.icon( - icon: const Icon(Icons.save), - label: const Text('保存'), - onPressed: _saveStaff, - ), - ], - ), + ), + _section( + title: '担当者情報', + subtitle: '署名や連絡先(送信者情報)', + child: Column( + children: [ + TextField(controller: _staffNameCtrl, decoration: const InputDecoration(labelText: '担当者名')), + TextField(controller: _staffMailCtrl, decoration: const InputDecoration(labelText: 'メールアドレス')), + const SizedBox(height: 8), + ElevatedButton.icon( + icon: const Icon(Icons.save), + label: const Text('保存'), + onPressed: _saveStaff, + ), + ], ), - _section( - title: 'SMTP情報', - subtitle: 'メール送信サーバ設定(テンプレ)', - child: Column( - children: [ - TextField(controller: _smtpHostCtrl, decoration: const InputDecoration(labelText: 'ホスト名')), - TextField(controller: _smtpPortCtrl, decoration: const InputDecoration(labelText: 'ポート番号'), keyboardType: TextInputType.number), - TextField(controller: _smtpUserCtrl, decoration: const InputDecoration(labelText: 'ユーザー名')), - TextField(controller: _smtpPassCtrl, decoration: const InputDecoration(labelText: 'パスワード'), obscureText: true), - TextField(controller: _smtpBccCtrl, decoration: const InputDecoration(labelText: 'BCC (カンマ区切り可)')), - SwitchListTile( - title: const Text('STARTTLS を使用'), - value: _smtpTls, - onChanged: (v) => setState(() => _smtpTls = v), - ), - ElevatedButton.icon( - icon: const Icon(Icons.save), - label: const Text('保存'), - onPressed: _saveSmtp, - ), - ], - ), + ), + _section( + title: 'SMTP情報', + subtitle: 'メール送信サーバ設定(テンプレ)', + child: Column( + children: [ + TextField(controller: _smtpHostCtrl, decoration: const InputDecoration(labelText: 'ホスト名')), + TextField(controller: _smtpPortCtrl, decoration: const InputDecoration(labelText: 'ポート番号'), keyboardType: TextInputType.number), + TextField(controller: _smtpUserCtrl, decoration: const InputDecoration(labelText: 'ユーザー名')), + TextField(controller: _smtpPassCtrl, decoration: const InputDecoration(labelText: 'パスワード'), obscureText: true), + TextField(controller: _smtpBccCtrl, decoration: const InputDecoration(labelText: 'BCC (カンマ区切り可)')), + SwitchListTile( + title: const Text('STARTTLS を使用'), + value: _smtpTls, + onChanged: (v) => setState(() => _smtpTls = v), + ), + ElevatedButton.icon( + icon: const Icon(Icons.save), + label: const Text('保存'), + onPressed: _saveSmtp, + ), + ], ), - _section( - title: '外部同期(母艦システム「お局様」連携)', - subtitle: '実行ボタンなし。ホストドメインとパスワードを入力してください。', - child: Column( - children: [ - TextField(controller: _externalHostCtrl, decoration: const InputDecoration(labelText: 'ホストドメイン')), - TextField(controller: _externalPassCtrl, decoration: const InputDecoration(labelText: 'パスワード'), obscureText: true), - const SizedBox(height: 8), - ElevatedButton.icon( - icon: const Icon(Icons.save), - label: const Text('保存'), - onPressed: _saveExternalSync, - ), - ], - ), + ), + _section( + title: '外部同期(母艦システム「お局様」連携)', + subtitle: '実行ボタンなし。ホストドメインとパスワードを入力してください。', + child: Column( + children: [ + TextField(controller: _externalHostCtrl, decoration: const InputDecoration(labelText: 'ホストドメイン')), + TextField(controller: _externalPassCtrl, decoration: const InputDecoration(labelText: 'パスワード'), obscureText: true), + const SizedBox(height: 8), + ElevatedButton.icon( + icon: const Icon(Icons.save), + label: const Text('保存'), + onPressed: _saveExternalSync, + ), + ], ), - _section( - title: 'バックアップドライブ', - subtitle: 'バックアップ先のクラウド/ローカル', - child: Column( - children: [ - TextField(controller: _backupPathCtrl, decoration: const InputDecoration(labelText: '保存先パス/URL')), - const SizedBox(height: 8), - Row( - children: [ - OutlinedButton.icon( - icon: const Icon(Icons.folder_open), - label: const Text('参照'), - onPressed: _pickBackupPath, + ), + _section( + title: 'バックアップドライブ', + subtitle: 'バックアップ先のクラウド/ローカル', + child: Column( + children: [ + TextField(controller: _backupPathCtrl, decoration: const InputDecoration(labelText: '保存先パス/URL')), + const SizedBox(height: 8), + Row( + children: [ + OutlinedButton.icon( + icon: const Icon(Icons.folder_open), + 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( + value: 'light', + groupValue: _theme, + title: const Text('ライト'), + onChanged: (v) => setState(() => _theme = v ?? 'light'), + ), + RadioListTile( + value: 'dark', + groupValue: _theme, + title: const Text('ダーク'), + onChanged: (v) => setState(() => _theme = v ?? 'dark'), + ), + RadioListTile( + 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( - icon: const Icon(Icons.save), - label: const Text('保存'), - onPressed: _saveBackup, + ), + 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, + ), + ], ), - _section( - title: 'テーマ選択', - subtitle: '配色や見た目を切り替え(テンプレ)', - child: Column( - children: [ - RadioListTile( - value: 'light', - groupValue: _theme, - title: const Text('ライト'), - onChanged: (v) => setState(() => _theme = v ?? 'light'), - ), - RadioListTile( - value: 'dark', - groupValue: _theme, - title: const Text('ダーク'), - onChanged: (v) => setState(() => _theme = v ?? 'dark'), - ), - RadioListTile( - 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, - ), - ], - ), - ), - ], - ), + ), + ], ), ), ); diff --git a/lib/widgets/keyboard_inset_wrapper.dart b/lib/widgets/keyboard_inset_wrapper.dart new file mode 100644 index 0000000..1b96845 --- /dev/null +++ b/lib/widgets/keyboard_inset_wrapper.dart @@ -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, + ), + ); + } +}