feat: add settings menu and explorer-style master UIs
This commit is contained in:
parent
145f0d7cad
commit
60fc0b46ac
6 changed files with 484 additions and 236 deletions
|
|
@ -12,8 +12,11 @@ class CustomerMasterScreen extends StatefulWidget {
|
||||||
|
|
||||||
class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
||||||
final CustomerRepository _customerRepo = CustomerRepository();
|
final CustomerRepository _customerRepo = CustomerRepository();
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
List<Customer> _customers = [];
|
List<Customer> _customers = [];
|
||||||
|
List<Customer> _filtered = [];
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
String _sortKey = 'name_asc';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -26,10 +29,26 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
||||||
final customers = await _customerRepo.getAllCustomers();
|
final customers = await _customerRepo.getAllCustomers();
|
||||||
setState(() {
|
setState(() {
|
||||||
_customers = customers;
|
_customers = customers;
|
||||||
|
_applyFilter();
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _applyFilter() {
|
||||||
|
final query = _searchController.text.toLowerCase();
|
||||||
|
List<Customer> list = _customers.where((c) {
|
||||||
|
return c.displayName.toLowerCase().contains(query) || c.formalName.toLowerCase().contains(query);
|
||||||
|
}).toList();
|
||||||
|
switch (_sortKey) {
|
||||||
|
case 'name_desc':
|
||||||
|
list.sort((a, b) => b.displayName.compareTo(a.displayName));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
list.sort((a, b) => a.displayName.compareTo(b.displayName));
|
||||||
|
}
|
||||||
|
_filtered = list;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _addOrEditCustomer({Customer? customer}) async {
|
Future<void> _addOrEditCustomer({Customer? customer}) async {
|
||||||
final isEdit = customer != null;
|
final isEdit = customer != null;
|
||||||
final displayNameController = TextEditingController(text: customer?.displayName ?? "");
|
final displayNameController = TextEditingController(text: customer?.displayName ?? "");
|
||||||
|
|
@ -116,49 +135,81 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
leading: const BackButton(),
|
leading: const BackButton(),
|
||||||
title: const Text("顧客マスター管理"),
|
title: const Text("顧客マスター"),
|
||||||
backgroundColor: Colors.blueGrey,
|
actions: [
|
||||||
|
DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton<String>(
|
||||||
|
value: _sortKey,
|
||||||
|
icon: const Icon(Icons.sort, color: Colors.white),
|
||||||
|
dropdownColor: Colors.white,
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(value: 'name_asc', child: Text('名前昇順')),
|
||||||
|
DropdownMenuItem(value: 'name_desc', child: Text('名前降順')),
|
||||||
|
],
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() {
|
||||||
|
_sortKey = v ?? 'name_asc';
|
||||||
|
_applyFilter();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
onPressed: _loadCustomers,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
body: _isLoading
|
body: Column(
|
||||||
? const Center(child: CircularProgressIndicator())
|
children: [
|
||||||
: _customers.isEmpty
|
Padding(
|
||||||
? const Center(child: Text("顧客が登録されていません"))
|
padding: const EdgeInsets.all(12),
|
||||||
: ListView.builder(
|
child: TextField(
|
||||||
itemCount: _customers.length,
|
controller: _searchController,
|
||||||
itemBuilder: (context, index) {
|
decoration: InputDecoration(
|
||||||
final c = _customers[index];
|
hintText: "名前で検索 (電話帳参照ボタンは詳細で)",
|
||||||
return ListTile(
|
prefixIcon: const Icon(Icons.search),
|
||||||
title: Text(c.displayName),
|
filled: true,
|
||||||
subtitle: Text("${c.formalName} ${c.title}"),
|
fillColor: Colors.white,
|
||||||
trailing: Row(
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
|
||||||
mainAxisSize: MainAxisSize.min,
|
),
|
||||||
children: [
|
onChanged: (_) => setState(_applyFilter),
|
||||||
IconButton(icon: const Icon(Icons.edit), onPressed: () => _addOrEditCustomer(customer: c)),
|
),
|
||||||
IconButton(
|
),
|
||||||
icon: const Icon(Icons.delete, color: Colors.red),
|
Expanded(
|
||||||
onPressed: () async {
|
child: _isLoading
|
||||||
final confirm = await showDialog<bool>(
|
? const Center(child: CircularProgressIndicator())
|
||||||
context: context,
|
: _filtered.isEmpty
|
||||||
builder: (context) => AlertDialog(
|
? const Center(child: Text("顧客が登録されていません"))
|
||||||
title: const Text("削除確認"),
|
: ListView.builder(
|
||||||
content: Text("「${c.displayName}」を削除しますか?"),
|
itemCount: _filtered.length,
|
||||||
actions: [
|
itemBuilder: (context, index) {
|
||||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("キャンセル")),
|
final c = _filtered[index];
|
||||||
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text("削除", style: TextStyle(color: Colors.red))),
|
return ListTile(
|
||||||
],
|
leading: CircleAvatar(
|
||||||
),
|
backgroundColor: c.isLocked ? Colors.grey.shade300 : Colors.indigo.shade100,
|
||||||
);
|
child: Stack(
|
||||||
if (confirm == true) {
|
children: [
|
||||||
await _customerRepo.deleteCustomer(c.id);
|
const Align(alignment: Alignment.center, child: Icon(Icons.person, color: Colors.indigo)),
|
||||||
_loadCustomers();
|
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: () => _showDetailPane(c),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.edit),
|
||||||
|
onPressed: c.isLocked ? null : () => _addOrEditCustomer(customer: c),
|
||||||
|
tooltip: c.isLocked ? "ロック中" : "編集",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
},
|
],
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
onPressed: () => _addOrEditCustomer(),
|
onPressed: () => _addOrEditCustomer(),
|
||||||
child: const Icon(Icons.person_add),
|
child: const Icon(Icons.person_add),
|
||||||
|
|
@ -166,4 +217,87 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showDetailPane(Customer c) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => DraggableScrollableSheet(
|
||||||
|
initialChildSize: 0.5,
|
||||||
|
maxChildSize: 0.8,
|
||||||
|
minChildSize: 0.4,
|
||||||
|
expand: false,
|
||||||
|
builder: (context, scrollController) => Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: ListView(
|
||||||
|
controller: scrollController,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(c.isLocked ? Icons.lock : Icons.person, color: c.isLocked ? Colors.redAccent : Colors.indigo),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Text(c.formalName, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18))),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.call),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("電話帳参照は端末連絡先連携が必要です")));
|
||||||
|
},
|
||||||
|
tooltip: "電話帳参照",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (c.address != null) Text("住所: ${c.address}") else const SizedBox.shrink(),
|
||||||
|
if (c.tel != null) Text("TEL: ${c.tel}") else const SizedBox.shrink(),
|
||||||
|
Text("敬称: ${c.title}"),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_addOrEditCustomer(customer: c);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.edit),
|
||||||
|
label: const Text("編集"),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
if (!c.isLocked)
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
final confirm = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text("削除確認"),
|
||||||
|
content: Text("「${c.displayName}」を削除しますか?"),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("キャンセル")),
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text("削除", style: TextStyle(color: Colors.red))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirm == true) {
|
||||||
|
await _customerRepo.deleteCustomer(c.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.pop(context);
|
||||||
|
_loadCustomers();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.delete_outline, color: Colors.redAccent),
|
||||||
|
label: const Text("削除", style: TextStyle(color: Colors.redAccent)),
|
||||||
|
),
|
||||||
|
if (c.isLocked)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8),
|
||||||
|
child: Chip(label: const Text("ロック中"), avatar: const Icon(Icons.lock, size: 16)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ import '../services/invoice_repository.dart';
|
||||||
import '../services/customer_repository.dart';
|
import '../services/customer_repository.dart';
|
||||||
import 'invoice_detail_page.dart';
|
import 'invoice_detail_page.dart';
|
||||||
import 'management_screen.dart';
|
import 'management_screen.dart';
|
||||||
|
import 'product_master_screen.dart';
|
||||||
|
import 'customer_master_screen.dart';
|
||||||
|
import 'settings_screen.dart';
|
||||||
import 'company_info_screen.dart';
|
import 'company_info_screen.dart';
|
||||||
import '../widgets/slide_to_unlock.dart';
|
import '../widgets/slide_to_unlock.dart';
|
||||||
import '../main.dart'; // InvoiceFlowScreen 用
|
import '../main.dart'; // InvoiceFlowScreen 用
|
||||||
|
|
@ -98,6 +101,63 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
||||||
final dateFormatter = DateFormat('yyyy/MM/dd');
|
final dateFormatter = DateFormat('yyyy/MM/dd');
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
drawer: Drawer(
|
||||||
|
child: ListView(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
children: [
|
||||||
|
DrawerHeader(
|
||||||
|
decoration: BoxDecoration(color: Colors.indigo.shade700),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
const Text("メニュー", style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text("v$_appVersion", style: const TextStyle(color: Colors.white70)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.receipt_long),
|
||||||
|
title: const Text("伝票マスター"),
|
||||||
|
onTap: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.people),
|
||||||
|
title: const Text("顧客マスター"),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
Navigator.push(context, MaterialPageRoute(builder: (_) => const CustomerMasterScreen()));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.inventory_2),
|
||||||
|
title: const Text("商品マスター"),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
Navigator.push(context, MaterialPageRoute(builder: (_) => const ProductMasterScreen()));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.settings),
|
||||||
|
title: const Text("設定"),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsScreen()));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.admin_panel_settings),
|
||||||
|
title: const Text("管理メニュー"),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
Navigator.push(context, MaterialPageRoute(builder: (_) => const ManagementScreen()));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
// leading removed
|
// leading removed
|
||||||
title: GestureDetector(
|
title: GestureDetector(
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,11 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
Customer? _selectedCustomer;
|
Customer? _selectedCustomer;
|
||||||
final List<InvoiceItem> _items = [];
|
final List<InvoiceItem> _items = [];
|
||||||
double _taxRate = 0.10;
|
double _taxRate = 0.10;
|
||||||
bool _includeTax = true;
|
bool _includeTax = false;
|
||||||
CompanyInfo? _companyInfo;
|
CompanyInfo? _companyInfo;
|
||||||
DocumentType _documentType = DocumentType.invoice; // 追加
|
DocumentType _documentType = DocumentType.invoice; // 追加
|
||||||
DateTime _selectedDate = DateTime.now(); // 追加: 伝票日付
|
DateTime _selectedDate = DateTime.now(); // 追加: 伝票日付
|
||||||
bool _isDraft = false; // 追加: 下書きモード
|
bool _isDraft = true; // デフォルトは下書き
|
||||||
final TextEditingController _subjectController = TextEditingController(); // 追加
|
final TextEditingController _subjectController = TextEditingController(); // 追加
|
||||||
bool _isSaving = false; // 保存中フラグ
|
bool _isSaving = false; // 保存中フラグ
|
||||||
String _status = "取引先と商品を入力してください";
|
String _status = "取引先と商品を入力してください";
|
||||||
|
|
@ -74,7 +74,9 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
_isDraft = inv.isDraft;
|
_isDraft = inv.isDraft;
|
||||||
if (inv.subject != null) _subjectController.text = inv.subject!;
|
if (inv.subject != null) _subjectController.text = inv.subject!;
|
||||||
} else {
|
} else {
|
||||||
_taxRate = companyInfo.defaultTaxRate;
|
_taxRate = 0;
|
||||||
|
_includeTax = false;
|
||||||
|
_isDraft = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -202,15 +204,14 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final fmt = NumberFormat("#,###");
|
final fmt = NumberFormat("#,###");
|
||||||
final themeColor = _isDraft ? Colors.blueGrey.shade800 : Colors.white;
|
final themeColor = Colors.white;
|
||||||
final textColor = _isDraft ? Colors.white : Colors.black87;
|
final textColor = Colors.black87;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: themeColor,
|
backgroundColor: themeColor,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
leading: const BackButton(),
|
leading: const BackButton(),
|
||||||
title: Text(_isDraft ? "伝票作成 (下書き)" : "販売アシスト1号 V1.5.06"),
|
title: const Text("販売アシスト1号 V1.5.06"),
|
||||||
backgroundColor: _isDraft ? Colors.black87 : Colors.blueGrey,
|
|
||||||
),
|
),
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -222,8 +223,6 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildDraftToggle(),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_buildDocumentTypeSection(),
|
_buildDocumentTypeSection(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildDateSection(),
|
_buildDateSection(),
|
||||||
|
|
@ -234,8 +233,6 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_buildItemsSection(fmt),
|
_buildItemsSection(fmt),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_buildTaxSettings(),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
_buildSummarySection(fmt),
|
_buildSummarySection(fmt),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_buildSignatureSection(),
|
_buildSignatureSection(),
|
||||||
|
|
@ -265,73 +262,36 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDocumentTypeSection() {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade200,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: DocumentType.values.map((type) {
|
|
||||||
final isSelected = _documentType == type;
|
|
||||||
String label = "";
|
|
||||||
IconData icon = Icons.description;
|
|
||||||
switch (type) {
|
|
||||||
case DocumentType.estimation: label = "見積"; icon = Icons.article_outlined; break;
|
|
||||||
case DocumentType.delivery: label = "納品"; icon = Icons.local_shipping_outlined; break;
|
|
||||||
case DocumentType.invoice: label = "請求"; icon = Icons.receipt_long_outlined; break;
|
|
||||||
case DocumentType.receipt: label = "領収"; icon = Icons.payments_outlined; break;
|
|
||||||
}
|
|
||||||
return Expanded(
|
|
||||||
child: InkWell(
|
|
||||||
onTap: () => setState(() => _documentType = type),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isSelected ? Colors.indigo : Colors.transparent,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Icon(icon, color: isSelected ? Colors.white : Colors.grey.shade600, size: 20),
|
|
||||||
Text(label, style: TextStyle(
|
|
||||||
color: isSelected ? Colors.white : Colors.grey.shade600,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
|
||||||
)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDateSection() {
|
Widget _buildDateSection() {
|
||||||
final fmt = DateFormat('yyyy年MM月dd日');
|
final fmt = DateFormat('yyyy/MM/dd');
|
||||||
return Card(
|
return GestureDetector(
|
||||||
elevation: 0,
|
onTap: () async {
|
||||||
color: Colors.blueGrey.shade50,
|
final picked = await showDatePicker(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
context: context,
|
||||||
child: ListTile(
|
initialDate: _selectedDate,
|
||||||
leading: const Icon(Icons.calendar_today, color: Colors.blueGrey),
|
firstDate: DateTime(2000),
|
||||||
title: Text("伝票日付: ${fmt.format(_selectedDate)}", style: const TextStyle(fontWeight: FontWeight.bold)),
|
lastDate: DateTime(2100),
|
||||||
subtitle: const Text("タップして日付を変更"),
|
);
|
||||||
trailing: const Icon(Icons.edit, size: 20),
|
if (picked != null) {
|
||||||
onTap: () async {
|
setState(() => _selectedDate = picked);
|
||||||
final picked = await showDatePicker(
|
}
|
||||||
context: context,
|
},
|
||||||
initialDate: _selectedDate,
|
child: Container(
|
||||||
firstDate: DateTime(2020),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
lastDate: DateTime.now().add(const Duration(days: 365)),
|
decoration: BoxDecoration(
|
||||||
);
|
color: Colors.grey.shade100,
|
||||||
if (picked != null) {
|
borderRadius: BorderRadius.circular(12),
|
||||||
setState(() => _selectedDate = picked);
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
}
|
),
|
||||||
},
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.calendar_today, size: 18, color: Colors.indigo),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text("伝票日付: ${fmt.format(_selectedDate)}", style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
const Spacer(),
|
||||||
|
const Icon(Icons.chevron_right, size: 18, color: Colors.indigo),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -477,55 +437,15 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTaxSettings() {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
if (_includeTax) ...[
|
|
||||||
const Text("消費税率: ", style: TextStyle(fontWeight: FontWeight.bold)),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
ChoiceChip(
|
|
||||||
label: const Text("10%"),
|
|
||||||
selected: _taxRate == 0.10,
|
|
||||||
onSelected: (val) => setState(() => _taxRate = 0.10),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
ChoiceChip(
|
|
||||||
label: const Text("8%"),
|
|
||||||
selected: _taxRate == 0.08,
|
|
||||||
onSelected: (val) => setState(() => _taxRate = 0.08),
|
|
||||||
),
|
|
||||||
] else
|
|
||||||
const Text("消費税設定: 非課税", style: TextStyle(fontWeight: FontWeight.bold, color: Colors.grey)),
|
|
||||||
const Spacer(),
|
|
||||||
Switch(
|
|
||||||
value: _includeTax,
|
|
||||||
onChanged: (val) => setState(() => _includeTax = val),
|
|
||||||
),
|
|
||||||
Text(_includeTax ? "税込計算" : "非課税"),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSummarySection(NumberFormat fmt) {
|
Widget _buildSummarySection(NumberFormat fmt) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(color: Colors.indigo.shade900, borderRadius: BorderRadius.circular(12)),
|
decoration: BoxDecoration(color: Colors.indigo.shade900, borderRadius: BorderRadius.circular(12)),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_buildSummaryRow(_includeTax ? "小計 (税抜)" : "小計", "¥${fmt.format(_subTotal)}", Colors.white70),
|
_buildSummaryRow("小計", "¥${fmt.format(_subTotal)}", Colors.white70),
|
||||||
if (_includeTax) ...[
|
|
||||||
if (_companyInfo?.taxDisplayMode == 'normal')
|
|
||||||
_buildSummaryRow("消費税 (${(_taxRate * 100).toInt()}%)", "¥${fmt.format(_tax)}", Colors.white70),
|
|
||||||
if (_companyInfo?.taxDisplayMode == 'text_only')
|
|
||||||
_buildSummaryRow("消費税", "(税別)", Colors.white70),
|
|
||||||
],
|
|
||||||
const Divider(color: Colors.white24),
|
const Divider(color: Colors.white24),
|
||||||
_buildSummaryRow(_includeTax ? "合計金額 (税込)" : "合計金額", "¥${fmt.format(_total)}", Colors.white, fontSize: 24),
|
_buildSummaryRow("合計金額", "¥${fmt.format(_subTotal)}", Colors.white, fontSize: 24),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -596,7 +516,7 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: _showPreview,
|
onPressed: _items.isEmpty ? null : _showPreview,
|
||||||
icon: const Icon(Icons.picture_as_pdf), // アイコン変更
|
icon: const Icon(Icons.picture_as_pdf), // アイコン変更
|
||||||
label: const Text("PDFプレビュー"), // 名称変更
|
label: const Text("PDFプレビュー"), // 名称変更
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
|
|
@ -610,9 +530,9 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: () => _saveInvoice(generatePdf: false),
|
onPressed: () => _saveInvoice(generatePdf: false),
|
||||||
icon: const Icon(Icons.save),
|
icon: const Icon(Icons.save),
|
||||||
label: const Text("保存のみ"),
|
label: const Text("保存"),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.blueGrey,
|
backgroundColor: Colors.indigo,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
),
|
),
|
||||||
|
|
@ -620,52 +540,12 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: () => _saveInvoice(generatePdf: true),
|
|
||||||
icon: const Icon(Icons.picture_as_pdf),
|
|
||||||
label: const Text("確定してPDF生成"),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
minimumSize: const Size(double.infinity, 56),
|
|
||||||
backgroundColor: Colors.indigo,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDraftToggle() {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: _isDraft ? Colors.black26 : Colors.orange.shade50,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: _isDraft ? Colors.orangeAccent : Colors.orange, width: 2),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(_isDraft ? Icons.drafts : Icons.check_circle, color: Colors.orange),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
_isDraft ? "下書き (保存のみ・PDF未生成)" : "正式発行 (PDF生成)",
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: _isDraft ? Colors.white70 : Colors.orange.shade900),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Switch(
|
|
||||||
value: _isDraft,
|
|
||||||
activeColor: Colors.orangeAccent,
|
|
||||||
onChanged: (val) => setState(() => _isDraft = val),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSubjectSection(Color textColor) {
|
Widget _buildSubjectSection(Color textColor) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
|
||||||
|
|
@ -159,37 +159,23 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final p = _filteredProducts[index];
|
final p = _filteredProducts[index];
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: const CircleAvatar(child: Icon(Icons.inventory_2)),
|
leading: CircleAvatar(
|
||||||
title: Text(p.name),
|
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})"),
|
subtitle: Text("${p.category ?? '未分類'} - ¥${p.defaultUnitPrice} (在庫: ${p.stockQuantity})"),
|
||||||
trailing: Row(
|
onTap: () => _showDetailPane(p),
|
||||||
mainAxisSize: MainAxisSize.min,
|
trailing: IconButton(
|
||||||
children: [
|
icon: const Icon(Icons.edit),
|
||||||
IconButton(icon: const Icon(Icons.edit), onPressed: () => _showEditDialog(product: p)),
|
onPressed: p.isLocked ? null : () => _showEditDialog(product: p),
|
||||||
IconButton(
|
tooltip: p.isLocked ? "ロック中" : "編集",
|
||||||
icon: const Icon(Icons.delete_outline, color: Colors.redAccent),
|
|
||||||
onPressed: () {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text("削除の確認"),
|
|
||||||
content: Text("${p.name}を削除してよろしいですか?"),
|
|
||||||
actions: [
|
|
||||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () async {
|
|
||||||
await _productRepo.deleteProduct(p.id);
|
|
||||||
Navigator.pop(context);
|
|
||||||
_loadProducts();
|
|
||||||
},
|
|
||||||
child: const Text("削除", style: TextStyle(color: Colors.red)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -202,4 +188,83 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showDetailPane(Product p) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => DraggableScrollableSheet(
|
||||||
|
initialChildSize: 0.45,
|
||||||
|
maxChildSize: 0.8,
|
||||||
|
minChildSize: 0.35,
|
||||||
|
expand: false,
|
||||||
|
builder: (context, scrollController) => Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: ListView(
|
||||||
|
controller: scrollController,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(p.isLocked ? Icons.lock : Icons.inventory_2, color: p.isLocked ? Colors.redAccent : Colors.indigo),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Text(p.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18))),
|
||||||
|
Chip(label: Text(p.category ?? '未分類')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text("単価: ¥${p.defaultUnitPrice}"),
|
||||||
|
Text("在庫: ${p.stockQuantity}"),
|
||||||
|
if (p.barcode != null && p.barcode!.isNotEmpty) Text("バーコード: ${p.barcode}"),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_showEditDialog(product: p);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.edit),
|
||||||
|
label: const Text("編集"),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
if (!p.isLocked)
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text("削除の確認"),
|
||||||
|
content: Text("${p.name}を削除してよろしいですか?"),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await _productRepo.deleteProduct(p.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.pop(context); // dialog
|
||||||
|
Navigator.pop(context); // sheet
|
||||||
|
_loadProducts();
|
||||||
|
},
|
||||||
|
child: const Text("削除", style: TextStyle(color: Colors.red)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.delete_outline, color: Colors.redAccent),
|
||||||
|
label: const Text("削除", style: TextStyle(color: Colors.redAccent)),
|
||||||
|
),
|
||||||
|
if (p.isLocked)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8),
|
||||||
|
child: Chip(label: const Text("ロック中"), avatar: const Icon(Icons.lock, size: 16)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
103
lib/screens/settings_screen.dart
Normal file
103
lib/screens/settings_screen.dart
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'company_info_screen.dart';
|
||||||
|
|
||||||
|
class SettingsScreen extends StatelessWidget {
|
||||||
|
const SettingsScreen({super.key});
|
||||||
|
|
||||||
|
void _showPlaceholder(BuildContext context, String title) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('$title の設定は後で追加してください')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showThemePicker(BuildContext context) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
|
||||||
|
builder: (context) => Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('テーマ選択', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.brightness_5),
|
||||||
|
title: const Text('ライト'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_showPlaceholder(context, 'ライトテーマ適用');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.brightness_3),
|
||||||
|
title: const Text('ダーク'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_showPlaceholder(context, 'ダークテーマ適用');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.brightness_auto),
|
||||||
|
title: const Text('システムに従う'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_showPlaceholder(context, 'システムテーマ適用');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('設定'),
|
||||||
|
),
|
||||||
|
body: ListView(
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.business),
|
||||||
|
title: const Text('自社情報'),
|
||||||
|
subtitle: const Text('会社名・住所・登録番号など'),
|
||||||
|
onTap: () async {
|
||||||
|
await Navigator.push(context, MaterialPageRoute(builder: (context) => const CompanyInfoScreen()));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.badge_outlined),
|
||||||
|
title: const Text('担当者情報'),
|
||||||
|
subtitle: const Text('自社担当者の署名・連絡先'),
|
||||||
|
onTap: () => _showPlaceholder(context, '担当者情報'),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.email_outlined),
|
||||||
|
title: const Text('SMTP情報'),
|
||||||
|
subtitle: const Text('メール送信サーバ設定'),
|
||||||
|
onTap: () => _showPlaceholder(context, 'SMTP情報'),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.cloud_upload_outlined),
|
||||||
|
title: const Text('バックアップドライブ'),
|
||||||
|
subtitle: const Text('バックアップ先のクラウド/ローカルドライブ'),
|
||||||
|
onTap: () => _showPlaceholder(context, 'バックアップドライブ'),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.palette_outlined),
|
||||||
|
title: const Text('テーマ選択'),
|
||||||
|
subtitle: const Text('配色や見た目を切り替え'),
|
||||||
|
onTap: () => _showThemePicker(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,8 +16,7 @@ Future<pw.Document> buildInvoiceDocument(Invoice invoice) async {
|
||||||
|
|
||||||
// フォントのロード
|
// フォントのロード
|
||||||
final fontData = await rootBundle.load("assets/fonts/ipaexg.ttf");
|
final fontData = await rootBundle.load("assets/fonts/ipaexg.ttf");
|
||||||
final ttf = pw.Font.ttf(fontData);
|
final ipaex = pw.Font.ttf(fontData);
|
||||||
final boldTtf = pw.Font.ttf(fontData);
|
|
||||||
|
|
||||||
final dateFormatter = DateFormat('yyyy年MM月dd日');
|
final dateFormatter = DateFormat('yyyy年MM月dd日');
|
||||||
final amountFormatter = NumberFormat("#,###");
|
final amountFormatter = NumberFormat("#,###");
|
||||||
|
|
@ -40,7 +39,14 @@ Future<pw.Document> buildInvoiceDocument(Invoice invoice) async {
|
||||||
pw.MultiPage(
|
pw.MultiPage(
|
||||||
pageFormat: PdfPageFormat.a4,
|
pageFormat: PdfPageFormat.a4,
|
||||||
margin: const pw.EdgeInsets.all(32),
|
margin: const pw.EdgeInsets.all(32),
|
||||||
theme: pw.ThemeData.withFont(base: ttf, bold: boldTtf),
|
theme: pw.ThemeData.withFont(
|
||||||
|
base: ipaex,
|
||||||
|
bold: ipaex,
|
||||||
|
italic: ipaex,
|
||||||
|
boldItalic: ipaex,
|
||||||
|
).copyWith(
|
||||||
|
defaultTextStyle: pw.TextStyle(fontFallback: [ipaex]),
|
||||||
|
),
|
||||||
build: (context) => [
|
build: (context) => [
|
||||||
// タイトル
|
// タイトル
|
||||||
pw.Header(
|
pw.Header(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue