分割に失敗、修復中
This commit is contained in:
parent
504a5a60cc
commit
c01a0b6775
11 changed files with 966 additions and 764 deletions
|
|
@ -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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
);
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
110
lib/screens/invoice_history/invoice_history_item.dart
Normal file
110
lib/screens/invoice_history/invoice_history_item.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
lib/screens/invoice_history/invoice_history_list.dart
Normal file
60
lib/screens/invoice_history/invoice_history_list.dart
Normal 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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
33
lib/widgets/keyboard_inset_wrapper.dart
Normal file
33
lib/widgets/keyboard_inset_wrapper.dart
Normal 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue