diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c5f3f6b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.configuration.updateBuildConfiguration": "interactive" +} \ No newline at end of file diff --git a/ios/Runner/GeneratedPluginRegistrant.m b/ios/Runner/GeneratedPluginRegistrant.m index 8c2ed7c..e3a94a6 100644 --- a/ios/Runner/GeneratedPluginRegistrant.m +++ b/ios/Runner/GeneratedPluginRegistrant.m @@ -60,6 +60,12 @@ @import share_plus; #endif +#if __has_include() +#import +#else +@import shared_preferences_foundation; +#endif + #if __has_include() #import #else @@ -84,6 +90,7 @@ [PermissionHandlerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PermissionHandlerPlugin"]]; [PrintingPlugin registerWithRegistrar:[registry registrarForPlugin:@"PrintingPlugin"]]; [FPPSharePlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPSharePlusPlugin"]]; + [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; [SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]]; [URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]]; } diff --git a/lib/main.dart b/lib/main.dart index 4767171..495810d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -61,12 +61,22 @@ class MyApp extends StatelessWidget { fontFamily: 'IPAexGothic', ), builder: (context, child) { - return InteractiveViewer( - panEnabled: false, - scaleEnabled: true, - minScale: 0.8, - maxScale: 2.0, - child: child ?? const SizedBox.shrink(), + final mq = MediaQuery.of(context); + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => FocusScope.of(context).unfocus(), + child: AnimatedPadding( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + padding: EdgeInsets.only(bottom: mq.viewInsets.bottom), + child: InteractiveViewer( + panEnabled: false, + scaleEnabled: true, + minScale: 0.8, + maxScale: 2.0, + child: child ?? const SizedBox.shrink(), + ), + ), ); }, home: const InvoiceHistoryScreen(), diff --git a/lib/models/company_model.dart b/lib/models/company_model.dart index a77620b..cc7dfe4 100644 --- a/lib/models/company_model.dart +++ b/lib/models/company_model.dart @@ -3,6 +3,9 @@ class CompanyInfo { final String? zipCode; final String? address; final String? tel; + final String? fax; + final String? email; + final String? url; final double defaultTaxRate; final String? sealPath; // 角印(印鑑)の画像パス final String taxDisplayMode; // 'normal', 'hidden', 'text_only' @@ -13,6 +16,9 @@ class CompanyInfo { this.zipCode, this.address, this.tel, + this.fax, + this.email, + this.url, this.defaultTaxRate = 0.10, this.sealPath, this.taxDisplayMode = 'normal', @@ -26,6 +32,9 @@ class CompanyInfo { 'zip_code': zipCode, 'address': address, 'tel': tel, + 'fax': fax, + 'email': email, + 'url': url, 'default_tax_rate': defaultTaxRate, 'seal_path': sealPath, 'tax_display_mode': taxDisplayMode, @@ -35,11 +44,14 @@ class CompanyInfo { factory CompanyInfo.fromMap(Map map) { return CompanyInfo( - name: map['name'] ?? "自社名未設定", + name: map['name'] ?? "", zipCode: map['zip_code'], address: map['address'], tel: map['tel'], - defaultTaxRate: map['default_tax_rate'] ?? 0.10, + fax: map['fax'], + email: map['email'], + url: map['url'], + defaultTaxRate: (map['default_tax_rate'] ?? 0.10).toDouble(), sealPath: map['seal_path'], taxDisplayMode: map['tax_display_mode'] ?? 'normal', registrationNumber: map['registration_number'], // 追加 @@ -51,6 +63,9 @@ class CompanyInfo { String? zipCode, String? address, String? tel, + String? fax, + String? email, + String? url, double? defaultTaxRate, String? sealPath, String? taxDisplayMode, @@ -61,6 +76,9 @@ class CompanyInfo { zipCode: zipCode ?? this.zipCode, address: address ?? this.address, tel: tel ?? this.tel, + fax: fax ?? this.fax, + email: email ?? this.email, + url: url ?? this.url, defaultTaxRate: defaultTaxRate ?? this.defaultTaxRate, sealPath: sealPath ?? this.sealPath, taxDisplayMode: taxDisplayMode ?? this.taxDisplayMode, diff --git a/lib/models/customer_model.dart b/lib/models/customer_model.dart index 2d31c9b..479a381 100644 --- a/lib/models/customer_model.dart +++ b/lib/models/customer_model.dart @@ -13,6 +13,8 @@ class Customer { final bool isSynced; // 同期フラグ final DateTime updatedAt; // 最終更新日時 final bool isLocked; // ロック + final String? headChar1; // インデックス1 + final String? headChar2; // インデックス2 Customer({ required this.id, @@ -28,6 +30,8 @@ class Customer { this.isSynced = false, DateTime? updatedAt, this.isLocked = false, + this.headChar1, + this.headChar2, }) : updatedAt = updatedAt ?? DateTime.now(); String get invoiceName { @@ -49,6 +53,8 @@ class Customer { 'tel': tel, 'contact_version_id': contactVersionId, 'odoo_id': odooId, + 'head_char1': headChar1, + 'head_char2': headChar2, 'is_locked': isLocked ? 1 : 0, 'is_synced': isSynced ? 1 : 0, 'updated_at': updatedAt.toIso8601String(), @@ -70,6 +76,8 @@ class Customer { isLocked: (map['is_locked'] ?? 0) == 1, isSynced: map['is_synced'] == 1, updatedAt: DateTime.parse(map['updated_at']), + headChar1: map['head_char1'], + headChar2: map['head_char2'], ); } @@ -87,6 +95,8 @@ class Customer { bool? isLocked, String? email, int? contactVersionId, + String? headChar1, + String? headChar2, }) { return Customer( id: id ?? this.id, @@ -102,6 +112,8 @@ class Customer { isSynced: isSynced ?? this.isSynced, updatedAt: updatedAt ?? this.updatedAt, isLocked: isLocked ?? this.isLocked, + headChar1: headChar1 ?? this.headChar1, + headChar2: headChar2 ?? this.headChar2, ); } } diff --git a/lib/screens/customer_master_screen.dart b/lib/screens/customer_master_screen.dart index 25185ee..ddaa774 100644 --- a/lib/screens/customer_master_screen.dart +++ b/lib/screens/customer_master_screen.dart @@ -1,10 +1,15 @@ import 'package:flutter/material.dart'; import 'package:uuid/uuid.dart'; +import 'package:flutter_contacts/flutter_contacts.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'dart:convert'; import '../models/customer_model.dart'; import '../services/customer_repository.dart'; class CustomerMasterScreen extends StatefulWidget { - const CustomerMasterScreen({Key? key}) : super(key: key); + final bool selectionMode; + + const CustomerMasterScreen({Key? key, this.selectionMode = false}) : super(key: key); @override State createState() => _CustomerMasterScreenState(); @@ -17,11 +22,67 @@ class _CustomerMasterScreenState extends State { List _filtered = []; bool _isLoading = true; String _sortKey = 'name_asc'; + bool _ignoreCorpPrefix = true; + String _activeKana = '全'; // temporarily unused (kana filter disabled) + Map _userKanaMap = {}; @override void initState() { super.initState(); - _loadCustomers(); + _init(); + } + + Future _init() async { + await _customerRepo.ensureCustomerColumns(); + await _loadUserKanaMap(); + if (!mounted) return; + await _loadCustomers(); + } + + Map _buildDefaultKanaMap() { + return { + // あ行 + '安': 'あ', '阿': 'あ', '浅': 'あ', '麻': 'あ', '新': 'あ', '青': 'あ', '赤': 'あ', '秋': 'あ', '明': 'あ', '有': 'あ', '伊': 'あ', + // か行 + '加': 'か', '鎌': 'か', '上': 'か', '川': 'か', '河': 'か', '北': 'か', '木': 'か', '菊': 'か', '岸': 'か', + '工': 'か', '古': 'か', '後': 'か', '郡': 'か', '久': 'か', '熊': 'か', '桑': 'か', '黒': 'か', '香': 'か', '金': 'か', '兼': 'か', '小': 'か', + // さ行 + '佐': 'さ', '齋': 'さ', '齊': 'さ', '斎': 'さ', '斉': 'さ', '崎': 'さ', '柴': 'さ', '沢': 'さ', '澤': 'さ', '桜': 'さ', '櫻': 'さ', + '酒': 'さ', '坂': 'さ', '榊': 'さ', '札': 'さ', '庄': 'し', '城': 'し', '島': 'さ', '嶋': 'さ', '鈴': 'さ', + // た行 + '田': 'た', '高': 'た', '竹': 'た', '滝': 'た', '瀧': 'た', '立': 'た', '達': 'た', '谷': 'た', '多': 'た', '千': 'た', '太': 'た', + // な行 + '中': 'な', '永': 'な', '長': 'な', '南': 'な', '難': 'な', + // は行 + '橋': 'は', '林': 'は', '原': 'は', '浜': 'は', '服': 'は', '福': 'は', '藤': 'は', '富': 'は', '保': 'は', '畠': 'は', '畑': 'は', + // ま行 + '松': 'ま', '前': 'ま', '真': 'ま', '町': 'ま', '間': 'ま', '馬': 'ま', + // や行 + '山': 'や', '矢': 'や', '柳': 'や', + // ら行 + '良': 'ら', '涼': 'ら', '竜': 'ら', + // わ行 + '渡': 'わ', '和': 'わ', + // その他 + '石': 'い', '井': 'い', '飯': 'い', '五': 'い', '吉': 'よ', '与': 'よ', '森': 'も', '守': 'も', + '岡': 'お', '奥': 'お', '尾': 'お', '黒': 'く', '久': 'く', '白': 'し', '志': 'し', '広': 'ひ', '弘': 'ひ', '平': 'ひ', '日': 'ひ', + '福': 'ふ', '藤': 'ふ', '布': 'ぬ', '内': 'う', '宇': 'う', '浦': 'う', '野': 'の', '能': 'の', + '宮': 'み', '三': 'み', '水': 'み', '溝': 'み', + }; + } + + Future _loadUserKanaMap() async { + final prefs = await SharedPreferences.getInstance(); + final json = prefs.getString('customKanaMap'); + if (json != null && json.isNotEmpty) { + try { + final Map decoded = jsonDecode(json); + _userKanaMap = decoded.map((k, v) => MapEntry(k, v.toString())); + if (mounted) setState(_applyFilter); + } catch (_) { + // ignore decode errors + } + } } Future _showContactUpdateDialog(Customer customer) async { @@ -67,12 +128,18 @@ class _CustomerMasterScreenState extends State { Future _loadCustomers() async { setState(() => _isLoading = true); - final customers = await _customerRepo.getAllCustomers(); - setState(() { - _customers = customers; - _applyFilter(); - _isLoading = false; - }); + try { + final customers = await _customerRepo.getAllCustomers(); + setState(() { + _customers = customers; + _applyFilter(); + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() => _isLoading = false); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('顧客の読み込みに失敗しました: $e'))); + } } void _applyFilter() { @@ -80,16 +147,91 @@ class _CustomerMasterScreenState extends State { List list = _customers.where((c) { return c.displayName.toLowerCase().contains(query) || c.formalName.toLowerCase().contains(query); }).toList(); + // Kana filtering disabled temporarily for stability switch (_sortKey) { case 'name_desc': - list.sort((a, b) => b.displayName.compareTo(a.displayName)); + list.sort((a, b) => _normalizedName(b.displayName).compareTo(_normalizedName(a.displayName))); break; default: - list.sort((a, b) => a.displayName.compareTo(b.displayName)); + list.sort((a, b) => _normalizedName(a.displayName).compareTo(_normalizedName(b.displayName))); } _filtered = list; } + String _normalizedName(String name) { + var n = name.replaceAll(RegExp(r"\s+"), ""); + if (_ignoreCorpPrefix) { + for (final token in ["株式会社", "(株)", "(株)", "有限会社", "(有)", "(有)", "合同会社", "(同)", "(同)"]) { + n = n.replaceAll(token, ""); + } + } + return n.toLowerCase(); + } + + final Map> _kanaBuckets = const { + 'あ': ['あ', 'い', 'う', 'え', 'お'], + 'か': ['か', 'き', 'く', 'け', 'こ', 'が', 'ぎ', 'ぐ', 'げ', 'ご'], + 'さ': ['さ', 'し', 'す', 'せ', 'そ', 'ざ', 'じ', 'ず', 'ぜ', 'ぞ'], + 'た': ['た', 'ち', 'つ', 'て', 'と', 'だ', 'ぢ', 'づ', 'で', 'ど'], + 'な': ['な', 'に', 'ぬ', 'ね', 'の'], + 'は': ['は', 'ひ', 'ふ', 'へ', 'ほ', 'ば', 'び', 'ぶ', 'べ', 'ぼ', 'ぱ', 'ぴ', 'ぷ', 'ぺ', 'ぽ'], + 'ま': ['ま', 'み', 'む', 'め', 'も'], + 'や': ['や', 'ゆ', 'よ'], + 'ら': ['ら', 'り', 'る', 'れ', 'ろ'], + 'わ': ['わ', 'を', 'ん'], + }; + + late final Map _defaultKanaMap = _buildDefaultKanaMap(); + + String _normalizeIndexChar(String input) { + var s = input.replaceAll(RegExp(r"\s+|\u3000"), ""); + if (s.isEmpty) return ''; + String ch = s.characters.first; + final code = ch.codeUnitAt(0); + if (code >= 0x30A1 && code <= 0x30F6) { + ch = String.fromCharCode(code - 0x60); // katakana -> hiragana + } + return ch; + } + + String _headForCustomer(Customer c) { + final head = c.headChar1 ?? ''; + if (head.isNotEmpty) { + return _bucketForChar(head); + } + return _headKana(c.displayName); + } + + String _bucketForChar(String ch) { + var c = _normalizeIndexChar(ch); + if (c.isEmpty) return '他'; + if (_userKanaMap.containsKey(c)) return _userKanaMap[c]!; + if (_defaultKanaMap.containsKey(c)) return _defaultKanaMap[c]!; + for (final entry in _kanaBuckets.entries) { + if (entry.value.contains(c)) return entry.key; + } + return '他'; + } + + String _headKana(String name) { + var n = name.replaceAll(RegExp(r"\s+"), ""); + for (final token in ["株式会社", "(株)", "(株)", "有限会社", "(有)", "(有)", "合同会社", "(同)", "(同)"]) { + if (n.startsWith(token)) n = n.substring(token.length); + } + if (n.isEmpty) return '他'; + String ch = n.characters.first; + if (_defaultKanaMap.containsKey(ch)) return _defaultKanaMap[ch]!; + // katakana to hiragana + final code = ch.codeUnitAt(0); + if (code >= 0x30A1 && code <= 0x30F6) { + ch = String.fromCharCode(code - 0x60); + } + for (final entry in _kanaBuckets.entries) { + if (entry.value.contains(ch)) return entry.key; + } + return '他'; + } + Future _addOrEditCustomer({Customer? customer}) async { final isEdit = customer != null; final displayNameController = TextEditingController(text: customer?.displayName ?? ""); @@ -97,7 +239,78 @@ class _CustomerMasterScreenState extends State { final departmentController = TextEditingController(text: customer?.department ?? ""); final addressController = TextEditingController(text: customer?.address ?? ""); final telController = TextEditingController(text: customer?.tel ?? ""); + final emailController = TextEditingController(text: customer?.email ?? ""); String selectedTitle = customer?.title ?? "様"; + bool isCompany = selectedTitle == '御中'; + final head1Controller = TextEditingController(text: customer?.headChar1 ?? _headKana(displayNameController.text)); + final head2Controller = TextEditingController(text: customer?.headChar2 ?? ""); + + Future prefillFromPhonebook() async { + if (!await FlutterContacts.requestPermission(readonly: true)) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('連絡先の権限がありません'))); + return; + } + final contacts = await FlutterContacts.getContacts(withProperties: true, withAccounts: true, withPhoto: false); + if (contacts.isEmpty) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('連絡先が見つかりません'))); + return; + } + final Contact? picked = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (ctx) => SafeArea( + child: SizedBox( + height: MediaQuery.of(ctx).size.height * 0.6, + child: ListView.builder( + itemCount: contacts.length, + itemBuilder: (_, i) { + final c = contacts[i]; + final orgCompany = (c.organizations.isNotEmpty ? c.organizations.first.company : '') ?? ''; + final personParts = [c.name.last, c.name.first].where((v) => v.isNotEmpty).toList(); + final person = personParts.isNotEmpty ? personParts.join(' ').trim() : c.displayName; + final label = orgCompany.isNotEmpty ? orgCompany : person; + return ListTile( + title: Text(label), + subtitle: person.isNotEmpty ? Text(person) : null, + onTap: () => Navigator.pop(ctx, c), + ); + }, + ), + ), + ), + ); + if (picked != null) { + final orgCompany = (picked.organizations.isNotEmpty ? picked.organizations.first.company : '') ?? ''; + final personParts = [picked.name.last, picked.name.first].where((v) => v.isNotEmpty).toList(); + final person = personParts.isNotEmpty ? personParts.join(' ').trim() : picked.displayName; + final chosen = orgCompany.isNotEmpty ? orgCompany : person; + displayNameController.text = chosen; + formalNameController.text = orgCompany.isNotEmpty ? orgCompany : person; + final addr = picked.addresses.isNotEmpty + ? picked.addresses.first + : null; + if (addr != null) { + final joined = [addr.postalCode, addr.state, addr.city, addr.street, addr.country] + .where((v) => v.isNotEmpty) + .join(' '); + addressController.text = joined; + } + if (picked.phones.isNotEmpty) { + telController.text = picked.phones.first.number; + } + if (picked.emails.isNotEmpty) { + emailController.text = picked.emails.first.address; + } + isCompany = orgCompany.isNotEmpty; + selectedTitle = isCompany ? '御中' : '様'; + if (head1Controller.text.isEmpty) { + head1Controller.text = _headKana(chosen); + } + if (mounted) setState(() {}); + } + } final result = await showDialog( context: context, @@ -111,16 +324,83 @@ class _CustomerMasterScreenState extends State { TextField( controller: displayNameController, decoration: const InputDecoration(labelText: "表示名(略称)", hintText: "例: 佐々木製作所"), + onChanged: (v) { + if (head1Controller.text.isEmpty) { + head1Controller.text = _headKana(v); + } + }, ), TextField( controller: formalNameController, decoration: const InputDecoration(labelText: "正式名称", hintText: "例: 株式会社 佐々木製作所"), ), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + icon: const Icon(Icons.contact_phone), + label: const Text('電話帳から引用'), + onPressed: prefillFromPhonebook, + ), + ), + Row( + children: [ + Expanded( + child: RadioListTile( + dense: true, + title: const Text('会社'), + value: true, + groupValue: isCompany, + onChanged: (v) { + setDialogState(() { + isCompany = v ?? true; + selectedTitle = '御中'; + }); + }, + ), + ), + Expanded( + child: RadioListTile( + dense: true, + title: const Text('個人'), + value: false, + groupValue: isCompany, + onChanged: (v) { + setDialogState(() { + isCompany = v ?? false; + selectedTitle = '様'; + }); + }, + ), + ), + ], + ), DropdownButtonFormField( value: selectedTitle, decoration: const InputDecoration(labelText: "敬称"), items: ["様", "御中", "殿", "貴社"].map((t) => DropdownMenuItem(value: t, child: Text(t))).toList(), - onChanged: (val) => selectedTitle = val ?? "様", + 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, @@ -135,6 +415,11 @@ class _CustomerMasterScreenState extends State { decoration: const InputDecoration(labelText: "電話番号"), keyboardType: TextInputType.phone, ), + TextField( + controller: emailController, + decoration: const InputDecoration(labelText: "メールアドレス"), + keyboardType: TextInputType.emailAddress, + ), ], ), ), @@ -145,6 +430,8 @@ class _CustomerMasterScreenState extends State { if (displayNameController.text.isEmpty || formalNameController.text.isEmpty) { return; } + final head1 = _normalizeIndexChar(head1Controller.text); + final head2 = _normalizeIndexChar(head2Controller.text); final newCustomer = Customer( id: customer?.id ?? const Uuid().v4(), displayName: displayNameController.text, @@ -153,8 +440,9 @@ class _CustomerMasterScreenState extends State { department: departmentController.text.isEmpty ? null : departmentController.text, address: addressController.text.isEmpty ? null : addressController.text, tel: telController.text.isEmpty ? null : telController.text, - odooId: customer?.odooId, - isSynced: false, + headChar1: head1.isEmpty ? _headKana(displayNameController.text) : head1, + headChar2: head2.isEmpty ? null : head2, + isLocked: customer?.isLocked ?? false, ); Navigator.pop(context, newCustomer); }, @@ -167,34 +455,93 @@ class _CustomerMasterScreenState extends State { if (result != null) { await _customerRepo.saveCustomer(result); - _loadCustomers(); + if (widget.selectionMode) { + if (!mounted) return; + Navigator.pop(context, result); + } else { + _loadCustomers(); + } } } Future _showPhonebookImport() async { - // 疑似電話帳データ(会社名/氏名/複数住所) - final phonebook = [ - { - 'company': '佐々木製作所', - 'person': '佐々木 太郎', - 'addresses': ['大阪府大阪市北区1-1-1', '東京都千代田区丸の内2-2-2'], - 'tel': '06-1234-5678', - 'emails': ['info@sasaki.co.jp', 'taro@sasaki.co.jp'], - }, - { - 'company': 'Gemini Solutions', - 'person': 'John Smith', - 'addresses': ['1 Infinite Loop, CA', '1600 Amphitheatre Pkwy, CA'], - 'tel': '03-9876-5432', - 'emails': ['contact@gemini.com', 'john.smith@gemini.com'], - }, - ]; + // 端末連絡先を取得 + if (!await FlutterContacts.requestPermission(readonly: true)) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('連絡先の権限がありません'))); + return; + } + + final contacts = await FlutterContacts.getContacts(withProperties: true, withAccounts: true, withPhoto: false); + // 一部端末では一覧取得で organization が空になることがあるため、詳細を再取得 + final detailedContacts = []; + for (final c in contacts) { + final full = await FlutterContacts.getContact(c.id, withProperties: true, withAccounts: true, withPhoto: false); + if (full != null) detailedContacts.add(full); + } + final sourceContacts = detailedContacts.isNotEmpty ? detailedContacts : contacts; + if (sourceContacts.isEmpty) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('連絡先が見つかりません'))); + return; + } + + final phonebook = sourceContacts.map((c) { + final orgCompany = (c.organizations.isNotEmpty ? c.organizations.first.company : '') ?? ''; + final personParts = [c.name.last, c.name.first].where((v) => v.isNotEmpty).toList(); + final person = personParts.isNotEmpty ? personParts.join(' ').trim() : c.displayName; + final addresses = c.addresses + .map((a) => [a.postalCode, a.state, a.city, a.street, a.country] + .where((v) => v.isNotEmpty) + .join(' ')) + .where((s) => s.trim().isNotEmpty) + .toList(); + final emails = c.emails.map((e) => e.address).where((e) => e.trim().isNotEmpty).toList(); + final tel = c.phones.isNotEmpty ? c.phones.first.number : null; + final chosenCompany = orgCompany; // 空なら空のまま + final chosenPerson = person.isNotEmpty ? person : c.displayName; + return { + 'company': chosenCompany, + 'person': chosenPerson, + 'addresses': addresses.isNotEmpty ? addresses : [''], + 'tel': tel, + 'emails': emails.isNotEmpty ? emails : [''], + }; + }).toList(); String selectedEntryId = '0'; - String selectedNameSource = 'company'; + String selectedNameSource = (phonebook.isNotEmpty && (phonebook.first['company'] as String).isNotEmpty) + ? 'company' + : ((phonebook.isNotEmpty && (phonebook.first['person'] as String).isNotEmpty) ? 'person' : 'person'); int selectedAddressIndex = 0; int selectedEmailIndex = 0; + final displayController = TextEditingController(); + final formalController = TextEditingController(); + final addressController = TextEditingController(); + final emailController = TextEditingController(); + + void applySelectionState() { + final entry = phonebook[int.parse(selectedEntryId)]; + if ((entry['company'] as String).isNotEmpty) { + selectedNameSource = 'company'; + } else if ((entry['person'] as String).isNotEmpty) { + selectedNameSource = 'person'; + } + final addresses = (entry['addresses'] as List); + final emails = (entry['emails'] as List); + final displayName = selectedNameSource == 'company' ? entry['company'] as String : entry['person'] as String; + final formalName = selectedNameSource == 'company' + ? '株式会社 ${entry['company']}' + : '${entry['person']} 様'; + displayController.text = displayName; + formalController.text = formalName; + addressController.text = addresses[selectedAddressIndex]; + emailController.text = emails.isNotEmpty ? emails[selectedEmailIndex] : ''; + } + + applySelectionState(); + final imported = await showDialog( context: context, builder: (context) => StatefulBuilder( @@ -202,17 +549,6 @@ class _CustomerMasterScreenState extends State { final entry = phonebook[int.parse(selectedEntryId)]; final addresses = (entry['addresses'] as List); final emails = (entry['emails'] as List); - final displayName = selectedNameSource == 'company' ? entry['company'] as String : entry['person'] as String; - final formalName = selectedNameSource == 'company' - ? '株式会社 ${entry['company']}' - : '${entry['person']} 様'; - final addressText = addresses[selectedAddressIndex]; - final emailText = emails.isNotEmpty ? emails[selectedEmailIndex] : ''; - - final displayController = TextEditingController(text: displayName); - final formalController = TextEditingController(text: formalName); - final addressController = TextEditingController(text: addressText); - final emailController = TextEditingController(text: emailText); return AlertDialog( title: const Text('電話帳から取り込む'), @@ -226,12 +562,23 @@ class _CustomerMasterScreenState extends State { items: phonebook .asMap() .entries - .map((e) => DropdownMenuItem(value: e.key.toString(), child: Text(e.value['company'] as String))) + .map((e) { + final comp = e.value['company'] as String; + final person = e.value['person'] as String; + final title = comp.isNotEmpty ? comp : (person.isNotEmpty ? person : '不明'); + return DropdownMenuItem(value: e.key.toString(), child: Text(title)); + }) .toList(), onChanged: (v) { setDialogState(() { selectedEntryId = v ?? '0'; selectedAddressIndex = 0; + selectedEmailIndex = 0; + final entry = phonebook[int.parse(selectedEntryId)]; + selectedNameSource = (entry['company'] as String).isNotEmpty + ? 'company' + : ((entry['person'] as String).isNotEmpty ? 'person' : 'person'); + applySelectionState(); }); }, ), @@ -245,7 +592,10 @@ class _CustomerMasterScreenState extends State { title: const Text('会社名'), value: 'company', groupValue: selectedNameSource, - onChanged: (v) => setDialogState(() => selectedNameSource = v ?? 'company'), + onChanged: (v) => setDialogState(() { + selectedNameSource = v ?? 'company'; + applySelectionState(); + }), ), ), Expanded( @@ -254,7 +604,10 @@ class _CustomerMasterScreenState extends State { title: const Text('氏名'), value: 'person', groupValue: selectedNameSource, - onChanged: (v) => setDialogState(() => selectedNameSource = v ?? 'person'), + onChanged: (v) => setDialogState(() { + selectedNameSource = v ?? 'person'; + applySelectionState(); + }), ), ), ], @@ -268,7 +621,10 @@ class _CustomerMasterScreenState extends State { .entries .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) .toList(), - onChanged: (v) => setDialogState(() => selectedAddressIndex = v ?? 0), + onChanged: (v) => setDialogState(() { + selectedAddressIndex = v ?? 0; + applySelectionState(); + }), ), const SizedBox(height: 8), DropdownButtonFormField( @@ -279,7 +635,10 @@ class _CustomerMasterScreenState extends State { .entries .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) .toList(), - onChanged: (v) => setDialogState(() => selectedEmailIndex = v ?? 0), + onChanged: (v) => setDialogState(() { + selectedEmailIndex = v ?? 0; + applySelectionState(); + }), ), const SizedBox(height: 12), TextField( @@ -335,8 +694,29 @@ class _CustomerMasterScreenState extends State { return Scaffold( appBar: AppBar( leading: const BackButton(), - title: const Text("顧客マスター"), + title: Text(widget.selectionMode ? "顧客を選択" : "顧客マスター"), actions: [ + IconButton( + icon: const Icon(Icons.sort), + tooltip: "ソート", + onPressed: () { + showMenu( + context: context, + position: const RelativeRect.fromLTRB(100, 80, 0, 0), + items: const [ + PopupMenuItem(value: 'name_asc', child: Text('名前昇順')), + PopupMenuItem(value: 'name_desc', child: Text('名前降順')), + ], + ).then((val) { + if (val != null) { + setState(() { + _sortKey = val; + _applyFilter(); + }); + } + }); + }, + ), DropdownButtonHideUnderline( child: DropdownButton( value: _sortKey, @@ -354,10 +734,11 @@ class _CustomerMasterScreenState extends State { }, ), ), - IconButton( - icon: const Icon(Icons.refresh), - onPressed: _loadCustomers, - ), + if (!widget.selectionMode) + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _loadCustomers, + ), ], ), body: Column( @@ -367,7 +748,7 @@ class _CustomerMasterScreenState extends State { child: TextField( controller: _searchController, decoration: InputDecoration( - hintText: "名前で検索 (電話帳参照ボタンは詳細で)", + hintText: widget.selectionMode ? "名前で検索して選択" : "名前で検索 (電話帳参照ボタンは詳細で)", prefixIcon: const Icon(Icons.search), filled: true, fillColor: Colors.white, @@ -376,6 +757,19 @@ class _CustomerMasterScreenState extends State { onChanged: (_) => setState(_applyFilter), ), ), + // Kana index temporarily disabled + if (!widget.selectionMode) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: SwitchListTile( + title: const Text('株式会社/有限会社などの接頭辞を無視してソート'), + value: _ignoreCorpPrefix, + onChanged: (v) => setState(() { + _ignoreCorpPrefix = v; + _applyFilter(); + }), + ), + ), Expanded( child: _isLoading ? const Center(child: CircularProgressIndicator()) @@ -398,12 +792,15 @@ class _CustomerMasterScreenState extends State { ), 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 ? "ロック中" : "編集", - ), + onTap: widget.selectionMode ? () => Navigator.pop(context, c) : () => _showDetailPane(c), + trailing: widget.selectionMode + ? null + : IconButton( + icon: const Icon(Icons.edit), + onPressed: c.isLocked ? null : () => _addOrEditCustomer(customer: c), + tooltip: c.isLocked ? "ロック中" : "編集", + ), + onLongPress: () => _showContextActions(c), ); }, ), @@ -411,15 +808,110 @@ class _CustomerMasterScreenState extends State { ], ), floatingActionButton: FloatingActionButton.extended( - onPressed: _showPhonebookImport, + onPressed: _showAddMenu, icon: const Icon(Icons.add), - label: const Text('電話帳から取り込む'), + label: const Text('顧客を追加'), backgroundColor: Colors.indigo, foregroundColor: Colors.white, ), ); } + void _showAddMenu() { + showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.edit_note), + title: const Text('手入力で新規作成'), + onTap: () { + Navigator.pop(context); + _addOrEditCustomer(); + }, + ), + ListTile( + leading: const Icon(Icons.contact_phone), + title: const Text('電話帳から取り込む'), + onTap: () { + Navigator.pop(context); + _showPhonebookImport(); + }, + ), + ], + ), + ), + ); + } + + void _showContextActions(Customer c) { + showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.info_outline), + title: const Text('詳細を表示'), + onTap: () { + Navigator.pop(context); + _showDetailPane(c); + }, + ), + ListTile( + leading: const Icon(Icons.edit), + title: const Text('編集'), + enabled: !c.isLocked, + onTap: c.isLocked + ? null + : () { + Navigator.pop(context); + _addOrEditCustomer(customer: c); + }, + ), + ListTile( + leading: const Icon(Icons.contact_mail), + title: const Text('連絡先を更新'), + onTap: () { + Navigator.pop(context); + _showContactUpdateDialog(c); + }, + ), + ListTile( + leading: const Icon(Icons.delete, color: Colors.redAccent), + title: const Text('削除', style: TextStyle(color: Colors.redAccent)), + enabled: !c.isLocked, + onTap: c.isLocked + ? null + : () async { + Navigator.pop(context); + final confirm = await showDialog( + 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; + _loadCustomers(); + } + }, + ), + ], + ), + ), + ); + } + void _showDetailPane(Customer c) { showModalBottomSheet( context: context, diff --git a/lib/screens/customer_picker_modal.dart b/lib/screens/customer_picker_modal.dart index e3cbbd4..a06a96f 100644 --- a/lib/screens/customer_picker_modal.dart +++ b/lib/screens/customer_picker_modal.dart @@ -45,7 +45,7 @@ class _CustomerPickerModalState extends State { setState(() => _isImportingFromContacts = true); try { if (await FlutterContacts.requestPermission(readonly: true)) { - final contacts = await FlutterContacts.getContacts(); + final contacts = await FlutterContacts.getContacts(withProperties: true, withAccounts: true, withPhoto: false); if (!mounted) return; setState(() => _isImportingFromContacts = false); @@ -56,9 +56,13 @@ class _CustomerPickerModalState extends State { ); if (selectedContact != null) { + final orgCompany = (selectedContact.organizations.isNotEmpty ? selectedContact.organizations.first.company : '') ?? ''; + final personName = selectedContact.displayName; + final display = orgCompany.isNotEmpty ? orgCompany : personName; + final formal = orgCompany.isNotEmpty ? orgCompany : personName; _showCustomerEditDialog( - displayName: selectedContact.displayName, - initialFormalName: selectedContact.displayName, + displayName: display, + initialFormalName: formal, ); } } @@ -121,8 +125,32 @@ class _CustomerPickerModalState extends State { TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")), ElevatedButton( onPressed: () async { + final formal = formalNameController.text.trim(); + if (formal.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('正式名称を入力してください'))); + return; + } + String normalize(String s) { + var n = s.replaceAll(RegExp(r"\s+|\u3000"), ""); + for (final token in ["株式会社", "(株)", "(株)", "有限会社", "(有)", "(有)", "合同会社", "(同)", "(同)"]) { + n = n.replaceAll(token, ""); + } + return n.toLowerCase(); + } + + final normalizedFormal = normalize(formal); + final duplicates = await _repository.getAllCustomers(); + final hasDuplicate = duplicates.any((c) { + final target = normalize(c.formalName); + return target == normalizedFormal && (existingCustomer == null || c.id != existingCustomer.id); + }); + if (hasDuplicate) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('同一顧客名が存在します'))); + return; + } + final updatedCustomer = existingCustomer?.copyWith( - formalName: formalNameController.text.trim(), + formalName: formal, department: departmentController.text.trim(), address: addressController.text.trim(), updatedAt: DateTime.now(), @@ -131,17 +159,17 @@ class _CustomerPickerModalState extends State { Customer( id: const Uuid().v4(), displayName: displayName, - formalName: formalNameController.text.trim(), + formalName: formal, department: departmentController.text.trim(), address: addressController.text.trim(), ); - + await _repository.saveCustomer(updatedCustomer); Navigator.pop(context); // エディットダイアログを閉じる - _onSearch(""); // リストを再読込 - - // 保存のついでに選択状態にするなら以下を有効化(今回は明示的にリストから選ばせる) - // widget.onCustomerSelected(updatedCustomer); + _onSearch(_searchQuery); // リスト再読込 + if (existingCustomer == null) { + widget.onCustomerSelected(updatedCustomer); + } }, child: const Text("保存してマスターに登録"), ), @@ -277,7 +305,11 @@ class _PhoneContactListSelectorState extends State<_PhoneContactListSelector> { void _onSearch(String q) { setState(() { _filtered = widget.contacts - .where((c) => c.displayName.toLowerCase().contains(q.toLowerCase())) + .where((c) { + final org = c.organizations.isNotEmpty ? c.organizations.first.company : ''; + final label = org.isNotEmpty ? org : c.displayName; + return label.toLowerCase().contains(q.toLowerCase()); + }) .toList(); }); } @@ -300,7 +332,9 @@ class _PhoneContactListSelectorState extends State<_PhoneContactListSelector> { child: ListView.builder( itemCount: _filtered.length, itemBuilder: (context, index) => ListTile( - title: Text(_filtered[index].displayName), + title: Text(((_filtered[index].organizations.isNotEmpty ? _filtered[index].organizations.first.company : '') ?? '').isNotEmpty + ? _filtered[index].organizations.first.company + : _filtered[index].displayName), onTap: () => Navigator.pop(context, _filtered[index]), ), ), diff --git a/lib/screens/invoice_input_screen.dart b/lib/screens/invoice_input_screen.dart index b3d1c42..44bb835 100644 --- a/lib/screens/invoice_input_screen.dart +++ b/lib/screens/invoice_input_screen.dart @@ -216,20 +216,17 @@ class _InvoiceInputFormState extends State { final fmt = NumberFormat("#,###"); final themeColor = Colors.white; final textColor = Colors.black87; - final bottomInset = MediaQuery.of(context).viewInsets.bottom; return Scaffold( backgroundColor: themeColor, - resizeToAvoidBottomInset: true, + resizeToAvoidBottomInset: false, appBar: AppBar( leading: const BackButton(), title: const Text("販売アシスト1号 V1.5.08"), ), body: Stack( children: [ - AnimatedPadding( - duration: const Duration(milliseconds: 200), - padding: EdgeInsets.only(bottom: bottomInset), + SafeArea( child: InteractiveViewer( panEnabled: false, minScale: 0.8, @@ -239,7 +236,7 @@ class _InvoiceInputFormState extends State { children: [ Expanded( child: SingleChildScrollView( - padding: EdgeInsets.fromLTRB(16, 16, 16, 140 + bottomInset), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 140), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -254,6 +251,7 @@ class _InvoiceInputFormState extends State { _buildSummarySection(fmt), const SizedBox(height: 20), _buildSignatureSection(), + const SizedBox(height: 12), ], ), ), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index d998478..12e856b 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; import 'company_info_screen.dart'; class SettingsScreen extends StatefulWidget { @@ -15,6 +17,9 @@ class _SettingsScreenState extends State { final _companyAddrCtrl = TextEditingController(); final _companyTelCtrl = TextEditingController(); final _companyRegCtrl = TextEditingController(); + final _companyFaxCtrl = TextEditingController(); + final _companyEmailCtrl = TextEditingController(); + final _companyUrlCtrl = TextEditingController(); // Staff final _staffNameCtrl = TextEditingController(); @@ -25,13 +30,50 @@ class _SettingsScreenState extends State { final _smtpPortCtrl = TextEditingController(text: '587'); final _smtpUserCtrl = TextEditingController(); final _smtpPassCtrl = TextEditingController(); + final _smtpBccCtrl = TextEditingController(); bool _smtpTls = true; + // External sync (母艦システム「お局様」連携) + final _externalHostCtrl = TextEditingController(); + final _externalPassCtrl = TextEditingController(); + // Backup final _backupPathCtrl = TextEditingController(); String _theme = 'system'; + // Kana map (kanji -> kana head) + Map _customKanaMap = {}; + final _kanaKeyCtrl = TextEditingController(); + final _kanaValCtrl = TextEditingController(); + + // SharedPreferences keys + static const _kCompanyName = 'company_name'; + static const _kCompanyZip = 'company_zip'; + static const _kCompanyAddr = 'company_addr'; + static const _kCompanyTel = 'company_tel'; + static const _kCompanyReg = 'company_reg'; + static const _kCompanyFax = 'company_fax'; + static const _kCompanyEmail = 'company_email'; + static const _kCompanyUrl = 'company_url'; + + static const _kStaffName = 'staff_name'; + static const _kStaffMail = 'staff_mail'; + + static const _kSmtpHost = 'smtp_host'; + static const _kSmtpPort = 'smtp_port'; + static const _kSmtpUser = 'smtp_user'; + static const _kSmtpPass = 'smtp_pass'; + static const _kSmtpTls = 'smtp_tls'; + static const _kSmtpBcc = 'smtp_bcc'; + + static const _kExternalHost = 'external_host'; + static const _kExternalPass = 'external_pass'; + + static const _kCryptKey = 'test'; + + static const _kBackupPath = 'backup_path'; + @override void dispose() { _companyNameCtrl.dispose(); @@ -39,30 +81,154 @@ class _SettingsScreenState extends State { _companyAddrCtrl.dispose(); _companyTelCtrl.dispose(); _companyRegCtrl.dispose(); + _companyFaxCtrl.dispose(); + _companyEmailCtrl.dispose(); + _companyUrlCtrl.dispose(); _staffNameCtrl.dispose(); _staffMailCtrl.dispose(); _smtpHostCtrl.dispose(); _smtpPortCtrl.dispose(); _smtpUserCtrl.dispose(); _smtpPassCtrl.dispose(); + _smtpBccCtrl.dispose(); + _externalHostCtrl.dispose(); + _externalPassCtrl.dispose(); _backupPathCtrl.dispose(); + _kanaKeyCtrl.dispose(); + _kanaValCtrl.dispose(); super.dispose(); } + Future _loadAll() async { + await _loadKanaMap(); + final prefs = await SharedPreferences.getInstance(); + setState(() { + _companyNameCtrl.text = prefs.getString(_kCompanyName) ?? ''; + _companyZipCtrl.text = prefs.getString(_kCompanyZip) ?? ''; + _companyAddrCtrl.text = prefs.getString(_kCompanyAddr) ?? ''; + _companyTelCtrl.text = prefs.getString(_kCompanyTel) ?? ''; + _companyRegCtrl.text = prefs.getString(_kCompanyReg) ?? ''; + _companyFaxCtrl.text = prefs.getString(_kCompanyFax) ?? ''; + _companyEmailCtrl.text = prefs.getString(_kCompanyEmail) ?? ''; + _companyUrlCtrl.text = prefs.getString(_kCompanyUrl) ?? ''; + + _staffNameCtrl.text = prefs.getString(_kStaffName) ?? ''; + _staffMailCtrl.text = prefs.getString(_kStaffMail) ?? ''; + + _smtpHostCtrl.text = prefs.getString(_kSmtpHost) ?? ''; + _smtpPortCtrl.text = prefs.getString(_kSmtpPort) ?? '587'; + _smtpUserCtrl.text = prefs.getString(_kSmtpUser) ?? ''; + _smtpPassCtrl.text = _decryptWithFallback(prefs.getString(_kSmtpPass) ?? ''); + _smtpTls = prefs.getBool(_kSmtpTls) ?? true; + _smtpBccCtrl.text = prefs.getString(_kSmtpBcc) ?? ''; + + _externalHostCtrl.text = prefs.getString(_kExternalHost) ?? ''; + _externalPassCtrl.text = prefs.getString(_kExternalPass) ?? ''; + + _backupPathCtrl.text = prefs.getString(_kBackupPath) ?? ''; + }); + } + + @override + void initState() { + super.initState(); + _loadAll(); + } + void _showSnackbar(String msg) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); } - void _saveCompany() => _showSnackbar('自社情報を保存(テンプレ)'); - void _saveStaff() => _showSnackbar('担当者情報を保存(テンプレ)'); - void _saveSmtp() => _showSnackbar('SMTP設定を保存(テンプレ)'); - void _saveBackup() => _showSnackbar('バックアップ設定を保存(テンプレ)'); + Future _saveCompany() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kCompanyName, _companyNameCtrl.text); + await prefs.setString(_kCompanyZip, _companyZipCtrl.text); + await prefs.setString(_kCompanyAddr, _companyAddrCtrl.text); + await prefs.setString(_kCompanyTel, _companyTelCtrl.text); + await prefs.setString(_kCompanyReg, _companyRegCtrl.text); + await prefs.setString(_kCompanyFax, _companyFaxCtrl.text); + await prefs.setString(_kCompanyEmail, _companyEmailCtrl.text); + await prefs.setString(_kCompanyUrl, _companyUrlCtrl.text); + _showSnackbar('自社情報を保存しました'); + } + + Future _saveStaff() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kStaffName, _staffNameCtrl.text); + await prefs.setString(_kStaffMail, _staffMailCtrl.text); + _showSnackbar('担当者情報を保存しました'); + } + + Future _saveSmtp() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kSmtpHost, _smtpHostCtrl.text); + await prefs.setString(_kSmtpPort, _smtpPortCtrl.text); + await prefs.setString(_kSmtpUser, _smtpUserCtrl.text); + await prefs.setString(_kSmtpPass, _encrypt(_smtpPassCtrl.text)); + await prefs.setBool(_kSmtpTls, _smtpTls); + await prefs.setString(_kSmtpBcc, _smtpBccCtrl.text); + _showSnackbar('SMTP設定を保存しました'); + } + + Future _saveExternalSync() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kExternalHost, _externalHostCtrl.text); + await prefs.setString(_kExternalPass, _externalPassCtrl.text); + _showSnackbar('外部同期設定を保存しました'); + } + + Future _saveBackup() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kBackupPath, _backupPathCtrl.text); + _showSnackbar('バックアップ設定を保存しました'); + } void _pickBackupPath() => _showSnackbar('バックアップ先の選択は後で実装'); + Future _loadKanaMap() async { + final prefs = await SharedPreferences.getInstance(); + final json = prefs.getString('customKanaMap'); + if (json != null && json.isNotEmpty) { + try { + final Map decoded = jsonDecode(json); + setState(() => _customKanaMap = decoded.map((k, v) => MapEntry(k, v.toString()))); + } catch (_) { + // ignore + } + } + } + + Future _saveKanaMap() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('customKanaMap', jsonEncode(_customKanaMap)); + _showSnackbar('かなインデックスを保存しました'); + } + + String _encrypt(String plain) { + if (plain.isEmpty) return ''; + final pb = utf8.encode(plain); + final kb = utf8.encode(_kCryptKey); + final ob = List.generate(pb.length, (i) => pb[i] ^ kb[i % kb.length]); + return base64Encode(ob); + } + + String _decryptWithFallback(String cipher) { + if (cipher.isEmpty) return ''; + try { + final ob = base64Decode(cipher); + final kb = utf8.encode(_kCryptKey); + final pb = List.generate(ob.length, (i) => ob[i] ^ kb[i % kb.length]); + return utf8.decode(pb); + } catch (_) { + return cipher; // 旧プレーンテキストも許容 + } + } + @override Widget build(BuildContext context) { + final bottomInset = MediaQuery.of(context).viewInsets.bottom; return Scaffold( + resizeToAvoidBottomInset: false, appBar: AppBar( title: const Text('設定'), actions: [ @@ -72,136 +238,220 @@ class _SettingsScreenState extends State { ) ], ), - body: ListView( - padding: const EdgeInsets.all(16), - children: [ - _section( - title: '自社情報', - subtitle: '会社名・住所・登録番号など', - child: Column( - children: [ - TextField(controller: _companyNameCtrl, decoration: const InputDecoration(labelText: '会社名')), - TextField(controller: _companyZipCtrl, decoration: const InputDecoration(labelText: '郵便番号')), - TextField(controller: _companyAddrCtrl, decoration: const InputDecoration(labelText: '住所')), - TextField(controller: _companyTelCtrl, decoration: const InputDecoration(labelText: '電話番号')), - TextField(controller: _companyRegCtrl, decoration: const InputDecoration(labelText: '登録番号 (インボイス)')), - const SizedBox(height: 8), - Row( + body: SafeArea( + child: AnimatedPadding( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + padding: EdgeInsets.only(bottom: bottomInset), + child: ListView( + padding: const EdgeInsets.all(16), + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + children: [ + _section( + title: '自社情報', + subtitle: '会社名・住所・登録番号など', + child: Column( children: [ - OutlinedButton.icon( - icon: const Icon(Icons.upload_file), - label: const Text('画面で編集'), - onPressed: () async { - await Navigator.push(context, MaterialPageRoute(builder: (context) => const CompanyInfoScreen())); - }, - ), - const SizedBox(width: 8), - ElevatedButton.icon( - icon: const Icon(Icons.save), - label: const Text('保存'), - onPressed: _saveCompany, + TextField(controller: _companyNameCtrl, decoration: const InputDecoration(labelText: '会社名')), + TextField(controller: _companyZipCtrl, decoration: const InputDecoration(labelText: '郵便番号')), + TextField(controller: _companyAddrCtrl, decoration: const InputDecoration(labelText: '住所')), + TextField(controller: _companyTelCtrl, decoration: const InputDecoration(labelText: '電話番号')), + TextField(controller: _companyFaxCtrl, decoration: const InputDecoration(labelText: 'FAX番号')), + TextField(controller: _companyEmailCtrl, decoration: const InputDecoration(labelText: 'メールアドレス')), + TextField(controller: _companyUrlCtrl, decoration: const InputDecoration(labelText: 'URL')), + TextField(controller: _companyRegCtrl, decoration: const InputDecoration(labelText: '登録番号 (インボイス)')), + const SizedBox(height: 8), + Row( + children: [ + OutlinedButton.icon( + icon: const Icon(Icons.upload_file), + label: const Text('画面で編集'), + onPressed: () async { + await Navigator.push(context, MaterialPageRoute(builder: (context) => const CompanyInfoScreen())); + }, + ), + const SizedBox(width: 8), + ElevatedButton.icon( + icon: const Icon(Icons.save), + label: const Text('保存'), + onPressed: _saveCompany, + ), + ], ), ], ), - ], - ), - ), - _section( - title: '担当者情報', - subtitle: '署名や連絡先(送信者情報)', - child: Column( - children: [ - TextField(controller: _staffNameCtrl, decoration: const InputDecoration(labelText: '担当者名')), - TextField(controller: _staffMailCtrl, decoration: const InputDecoration(labelText: 'メールアドレス')), - const SizedBox(height: 8), - ElevatedButton.icon( - icon: const Icon(Icons.save), - label: const Text('保存'), - onPressed: _saveStaff, - ), - ], - ), - ), - _section( - title: 'SMTP情報', - subtitle: 'メール送信サーバ設定(テンプレ)', - child: Column( - children: [ - TextField(controller: _smtpHostCtrl, decoration: const InputDecoration(labelText: 'ホスト名')), - TextField(controller: _smtpPortCtrl, decoration: const InputDecoration(labelText: 'ポート番号'), keyboardType: TextInputType.number), - TextField(controller: _smtpUserCtrl, decoration: const InputDecoration(labelText: 'ユーザー名')), - TextField(controller: _smtpPassCtrl, decoration: const InputDecoration(labelText: 'パスワード'), obscureText: true), - SwitchListTile( - title: const Text('STARTTLS を使用'), - value: _smtpTls, - onChanged: (v) => setState(() => _smtpTls = v), - ), - ElevatedButton.icon( - icon: const Icon(Icons.save), - label: const Text('保存'), - onPressed: _saveSmtp, - ), - ], - ), - ), - _section( - title: 'バックアップドライブ', - subtitle: 'バックアップ先のクラウド/ローカル', - child: Column( - children: [ - TextField(controller: _backupPathCtrl, decoration: const InputDecoration(labelText: '保存先パス/URL')), - const SizedBox(height: 8), - Row( + ), + _section( + title: '担当者情報', + subtitle: '署名や連絡先(送信者情報)', + child: Column( children: [ - OutlinedButton.icon( - icon: const Icon(Icons.folder_open), - label: const Text('参照'), - onPressed: _pickBackupPath, - ), - const SizedBox(width: 8), + TextField(controller: _staffNameCtrl, decoration: const InputDecoration(labelText: '担当者名')), + TextField(controller: _staffMailCtrl, decoration: const InputDecoration(labelText: 'メールアドレス')), + const SizedBox(height: 8), ElevatedButton.icon( icon: const Icon(Icons.save), label: const Text('保存'), - onPressed: _saveBackup, + onPressed: _saveStaff, ), ], ), - ], - ), + ), + _section( + title: 'SMTP情報', + subtitle: 'メール送信サーバ設定(テンプレ)', + child: Column( + children: [ + TextField(controller: _smtpHostCtrl, decoration: const InputDecoration(labelText: 'ホスト名')), + TextField(controller: _smtpPortCtrl, decoration: const InputDecoration(labelText: 'ポート番号'), keyboardType: TextInputType.number), + TextField(controller: _smtpUserCtrl, decoration: const InputDecoration(labelText: 'ユーザー名')), + TextField(controller: _smtpPassCtrl, decoration: const InputDecoration(labelText: 'パスワード'), obscureText: true), + TextField(controller: _smtpBccCtrl, decoration: const InputDecoration(labelText: 'BCC (カンマ区切り可)')), + SwitchListTile( + title: const Text('STARTTLS を使用'), + value: _smtpTls, + onChanged: (v) => setState(() => _smtpTls = v), + ), + ElevatedButton.icon( + icon: const Icon(Icons.save), + label: const Text('保存'), + onPressed: _saveSmtp, + ), + ], + ), + ), + _section( + title: '外部同期(母艦システム「お局様」連携)', + subtitle: '実行ボタンなし。ホストドメインとパスワードを入力してください。', + child: Column( + children: [ + TextField(controller: _externalHostCtrl, decoration: const InputDecoration(labelText: 'ホストドメイン')), + TextField(controller: _externalPassCtrl, decoration: const InputDecoration(labelText: 'パスワード'), obscureText: true), + const SizedBox(height: 8), + ElevatedButton.icon( + icon: const Icon(Icons.save), + label: const Text('保存'), + onPressed: _saveExternalSync, + ), + ], + ), + ), + _section( + title: 'バックアップドライブ', + subtitle: 'バックアップ先のクラウド/ローカル', + child: Column( + children: [ + TextField(controller: _backupPathCtrl, decoration: const InputDecoration(labelText: '保存先パス/URL')), + const SizedBox(height: 8), + Row( + children: [ + OutlinedButton.icon( + icon: const Icon(Icons.folder_open), + label: const Text('参照'), + onPressed: _pickBackupPath, + ), + const SizedBox(width: 8), + ElevatedButton.icon( + icon: const Icon(Icons.save), + label: const Text('保存'), + onPressed: _saveBackup, + ), + ], + ), + ], + ), + ), + _section( + title: 'テーマ選択', + subtitle: '配色や見た目を切り替え(テンプレ)', + child: Column( + children: [ + RadioListTile( + value: 'light', + groupValue: _theme, + title: const Text('ライト'), + onChanged: (v) => setState(() => _theme = v ?? 'light'), + ), + RadioListTile( + value: 'dark', + groupValue: _theme, + title: const Text('ダーク'), + onChanged: (v) => setState(() => _theme = v ?? 'dark'), + ), + RadioListTile( + value: 'system', + groupValue: _theme, + title: const Text('システムに従う'), + onChanged: (v) => setState(() => _theme = v ?? 'system'), + ), + const SizedBox(height: 8), + ElevatedButton.icon( + icon: const Icon(Icons.save), + label: const Text('保存'), + onPressed: () => _showSnackbar('テーマ設定を保存(テンプレ): $_theme'), + ), + ], + ), + ), + _section( + title: 'かなインデックス追加', + subtitle: '漢字→行(1文字ずつ)を追加して索引を補強', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: TextField( + controller: _kanaKeyCtrl, + maxLength: 1, + decoration: const InputDecoration(labelText: '漢字1文字', counterText: ''), + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: _kanaValCtrl, + maxLength: 1, + decoration: const InputDecoration(labelText: '行(例: さ)', counterText: ''), + ), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: () { + final k = _kanaKeyCtrl.text.trim(); + final v = _kanaValCtrl.text.trim(); + if (k.isEmpty || v.isEmpty) return; + setState(() { + _customKanaMap[k] = v; + }); + }, + child: const Text('追加'), + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 6, + children: _customKanaMap.entries + .map((e) => Chip( + label: Text('${e.key}: ${e.value}'), + onDeleted: () => setState(() => _customKanaMap.remove(e.key)), + )) + .toList(), + ), + const SizedBox(height: 8), + ElevatedButton.icon( + icon: const Icon(Icons.save), + label: const Text('保存'), + onPressed: _saveKanaMap, + ), + ], + ), + ), + ], ), - _section( - title: 'テーマ選択', - subtitle: '配色や見た目を切り替え(テンプレ)', - child: Column( - children: [ - RadioListTile( - value: 'light', - groupValue: _theme, - title: const Text('ライト'), - onChanged: (v) => setState(() => _theme = v ?? 'light'), - ), - RadioListTile( - value: 'dark', - groupValue: _theme, - title: const Text('ダーク'), - onChanged: (v) => setState(() => _theme = v ?? 'dark'), - ), - RadioListTile( - value: 'system', - groupValue: _theme, - title: const Text('システムに従う'), - onChanged: (v) => setState(() => _theme = v ?? 'system'), - ), - const SizedBox(height: 8), - ElevatedButton.icon( - icon: const Icon(Icons.save), - label: const Text('保存'), - onPressed: () => _showSnackbar('テーマ設定を保存(テンプレ): $_theme'), - ), - ], - ), - ), - ], + ), ), ); } diff --git a/lib/services/company_repository.dart b/lib/services/company_repository.dart index eee4ee3..90e138d 100644 --- a/lib/services/company_repository.dart +++ b/lib/services/company_repository.dart @@ -5,18 +5,56 @@ import 'database_helper.dart'; class CompanyRepository { final DatabaseHelper _dbHelper = DatabaseHelper(); + Future ensureCompanyColumns() async { + final db = await _dbHelper.database; + try { + await db.execute('ALTER TABLE company_info ADD COLUMN fax TEXT'); + } catch (_) {} + try { + await db.execute('ALTER TABLE company_info ADD COLUMN email TEXT'); + } catch (_) {} + try { + await db.execute('ALTER TABLE company_info ADD COLUMN url TEXT'); + } catch (_) {} + } + Future getCompanyInfo() async { final db = await _dbHelper.database; final List> maps = await db.query('company_info', where: 'id = 1'); if (maps.isEmpty) { - // 初期値 - return CompanyInfo(name: "販売アシスト1号 登録企業"); + final sample = CompanyInfo( + name: "販売アシスト1号 サンプル会社", + zipCode: "100-0001", + address: "東京都千代田区サンプル1-1-1", + tel: "03-1234-5678", + fax: "03-1234-5679", + email: "info@example.com", + url: "https://example.com", + registrationNumber: "T1234567890123", + ); + await saveCompanyInfo(sample.copyWith(defaultTaxRate: 0.10)); + return sample; } - return CompanyInfo.fromMap(maps.first); + final map = maps.first; + final company = CompanyInfo( + name: map['name'] ?? '', + zipCode: map['zip_code'], + address: map['address'], + tel: map['tel'], + fax: map['fax'], + email: map['email'], + url: map['url'], + defaultTaxRate: (map['default_tax_rate'] ?? 0.10).toDouble(), + sealPath: map['seal_path'], + taxDisplayMode: map['tax_display_mode'] ?? 'normal', + registrationNumber: map['registration_number'], + ); + return company; } Future saveCompanyInfo(CompanyInfo info) async { final db = await _dbHelper.database; + await ensureCompanyColumns(); await db.insert( 'company_info', info.toMap(), diff --git a/lib/services/customer_repository.dart b/lib/services/customer_repository.dart index ad9dc43..5b1d67b 100644 --- a/lib/services/customer_repository.dart +++ b/lib/services/customer_repository.dart @@ -11,35 +11,47 @@ class CustomerRepository { Future> getAllCustomers() async { final db = await _dbHelper.database; - final List> maps = await db.rawQuery(''' + List> maps = await db.rawQuery(''' SELECT c.*, cc.address AS contact_address, cc.tel AS contact_tel, cc.email AS contact_email FROM customers c LEFT JOIN customer_contacts cc ON cc.customer_id = c.id AND cc.is_active = 1 ORDER BY c.display_name ASC '''); - if (maps.isEmpty) { - await _generateSampleCustomers(); - return getAllCustomers(); // 再帰的に読み込み + await _generateSampleCustomers(limit: 3); + maps = await db.rawQuery(''' + SELECT c.*, cc.address AS contact_address, cc.tel AS contact_tel, cc.email AS contact_email + FROM customers c + LEFT JOIN customer_contacts cc ON cc.customer_id = c.id AND cc.is_active = 1 + ORDER BY c.display_name ASC + '''); } - return List.generate(maps.length, (i) => Customer.fromMap(maps[i])); } - Future _generateSampleCustomers() async { + Future ensureCustomerColumns() async { + final db = await _dbHelper.database; + // best-effort, ignore errors if columns already exist + try { + await db.execute('ALTER TABLE customers ADD COLUMN contact_version_id INTEGER'); + } catch (_) {} + try { + await db.execute('ALTER TABLE customers ADD COLUMN head_char1 TEXT'); + } catch (_) {} + try { + await db.execute('ALTER TABLE customers ADD COLUMN head_char2 TEXT'); + } catch (_) {} + } + + Future _generateSampleCustomers({int limit = 3}) async { final samples = [ - Customer(id: const Uuid().v4(), displayName: "佐々木製作所", formalName: "株式会社 佐々木製作所", title: "御中"), - Customer(id: const Uuid().v4(), displayName: "田中商事", formalName: "田中商事 株式会社", title: "様"), - Customer(id: const Uuid().v4(), displayName: "山田建材", formalName: "有限会社 山田建材", title: "御中"), - Customer(id: const Uuid().v4(), displayName: "鈴木運送", formalName: "鈴木運送 合同会社", title: "様"), - Customer(id: const Uuid().v4(), displayName: "伊藤工務店", formalName: "伊藤工務店", title: "様"), - Customer(id: const Uuid().v4(), displayName: "渡辺興業", formalName: "株式会社 渡辺興業", title: "御中"), - Customer(id: const Uuid().v4(), displayName: "高橋電気", formalName: "高橋電気工業所", title: "様"), - Customer(id: const Uuid().v4(), displayName: "佐藤商店", formalName: "佐藤商店", title: "様"), - Customer(id: const Uuid().v4(), displayName: "中村機械", formalName: "中村機械製作所", title: "殿"), - Customer(id: const Uuid().v4(), displayName: "小林産業", formalName: "小林産業 株式会社", title: "御中"), + Customer(id: const Uuid().v4(), displayName: "佐々木製作所", formalName: "株式会社 佐々木製作所", title: "御中", tel: "03-1111-2222", address: "東京都港区1-1-1"), + Customer(id: const Uuid().v4(), displayName: "田中商事", formalName: "田中商事 株式会社", title: "様", tel: "03-3333-4444", address: "東京都中央区2-2-2"), + Customer(id: const Uuid().v4(), displayName: "山田建材", formalName: "有限会社 山田建材", title: "御中", tel: "045-555-6666", address: "神奈川県横浜市3-3-3"), + Customer(id: const Uuid().v4(), displayName: "鈴木運送", formalName: "鈴木運送 合同会社", title: "様", tel: "052-777-8888", address: "愛知県名古屋市4-4-4"), + Customer(id: const Uuid().v4(), displayName: "伊藤工務店", formalName: "伊藤工務店", title: "様", tel: "06-9999-0000", address: "大阪府大阪市5-5-5"), ]; - for (var s in samples) { + for (var s in samples.take(limit)) { await saveCustomer(s); } } diff --git a/lib/services/database_helper.dart b/lib/services/database_helper.dart index a8c1b94..eacf05a 100644 --- a/lib/services/database_helper.dart +++ b/lib/services/database_helper.dart @@ -2,7 +2,7 @@ import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart'; class DatabaseHelper { - static const _databaseVersion = 17; + static const _databaseVersion = 20; static final DatabaseHelper _instance = DatabaseHelper._internal(); static Database? _database; @@ -38,6 +38,9 @@ class DatabaseHelper { zip_code TEXT, address TEXT, tel TEXT, + fax TEXT, + email TEXT, + url TEXT, default_tax_rate REAL DEFAULT 0.10, seal_path TEXT ) @@ -144,6 +147,23 @@ class DatabaseHelper { await _safeAddColumn(db, 'invoices', 'contact_tel_snapshot TEXT'); await _safeAddColumn(db, 'invoices', 'contact_address_snapshot TEXT'); } + if (oldVersion < 20) { + await _safeAddColumn(db, 'company_info', 'fax TEXT'); + await _safeAddColumn(db, 'company_info', 'email TEXT'); + await _safeAddColumn(db, 'company_info', 'url TEXT'); + } + if (oldVersion < 18) { + await _safeAddColumn(db, 'customers', 'contact_version_id INTEGER'); + } + if (oldVersion < 19) { + await _safeAddColumn(db, 'customers', 'head_char1 TEXT'); + await _safeAddColumn(db, 'customers', 'head_char2 TEXT'); + await db.execute('CREATE INDEX IF NOT EXISTS idx_customers_head1 ON customers(head_char1)'); + await db.execute('CREATE INDEX IF NOT EXISTS idx_customers_head2 ON customers(head_char2)'); + } + if (oldVersion < 20) { + await _safeAddColumn(db, 'customers', 'email TEXT'); + } } Future _onCreate(Database db, int version) async { @@ -156,7 +176,11 @@ class DatabaseHelper { department TEXT, address TEXT, tel TEXT, + email TEXT, + contact_version_id INTEGER, odoo_id TEXT, + head_char1 TEXT, + head_char2 TEXT, is_locked INTEGER DEFAULT 0, is_synced INTEGER DEFAULT 0, updated_at TEXT NOT NULL diff --git a/lib/services/invoice_repository.dart b/lib/services/invoice_repository.dart index 5c3c2a9..da4168f 100644 --- a/lib/services/invoice_repository.dart +++ b/lib/services/invoice_repository.dart @@ -96,7 +96,13 @@ class InvoiceRepository { Future> getAllInvoices(List customers) async { final db = await _dbHelper.database; - final List> invoiceMaps = await db.query('invoices', orderBy: 'date DESC'); + List> invoiceMaps = await db.query('invoices', orderBy: 'date DESC'); + + // サンプル自動投入(伝票が0件なら) + if (invoiceMaps.isEmpty && customers.isNotEmpty) { + await _generateSampleInvoices(customers.take(3).toList()); + invoiceMaps = await db.query('invoices', orderBy: 'date DESC'); + } List invoices = []; for (var iMap in invoiceMaps) { @@ -149,6 +155,29 @@ class InvoiceRepository { return invoices; } + Future _generateSampleInvoices(List customers) async { + if (customers.isEmpty) return; + final now = DateTime.now(); + final items = [ + InvoiceItem(description: "商品A", quantity: 2, unitPrice: 1200), + InvoiceItem(description: "商品B", quantity: 1, unitPrice: 3000), + ]; + final List samples = List.generate( + 3, + (i) => Invoice( + customer: customers[i % customers.length], + date: now.subtract(Duration(days: i * 3)), + items: items, + isDraft: i == 0, // 1件だけ下書き + subject: "サンプル案件${i + 1}", + ), + ); + + for (final inv in samples) { + await saveInvoice(inv); + } + } + Future deleteInvoice(String id) async { final db = await _dbHelper.database; await db.transaction((txn) async { diff --git a/lib/services/pdf_generator.dart b/lib/services/pdf_generator.dart index 56ef12c..c0d41b0 100644 --- a/lib/services/pdf_generator.dart +++ b/lib/services/pdf_generator.dart @@ -120,6 +120,9 @@ Future buildInvoiceDocument(Invoice invoice) async { if (companyInfo.zipCode != null) pw.Text("〒${companyInfo.zipCode}"), if (companyInfo.address != null) pw.Text(companyInfo.address!), if (companyInfo.tel != null) pw.Text("TEL: ${companyInfo.tel}"), + if (companyInfo.fax != null && companyInfo.fax!.isNotEmpty) pw.Text("FAX: ${companyInfo.fax}"), + if (companyInfo.email != null && companyInfo.email!.isNotEmpty) pw.Text("MAIL: ${companyInfo.email}"), + if (companyInfo.url != null && companyInfo.url!.isNotEmpty) pw.Text("URL: ${companyInfo.url}"), if (companyInfo.registrationNumber != null && companyInfo.registrationNumber!.isNotEmpty) pw.Text("登録番号: ${companyInfo.registrationNumber!}", style: const pw.TextStyle(fontSize: 10)), ], @@ -167,6 +170,12 @@ Future buildInvoiceDocument(Invoice invoice) async { headerDecoration: const pw.BoxDecoration(color: PdfColors.grey300), cellAlignment: pw.Alignment.centerLeft, columnWidths: const {0: pw.FlexColumnWidth(3), 1: pw.FlexColumnWidth(1), 2: pw.FlexColumnWidth(2), 3: pw.FlexColumnWidth(2)}, + cellAlignments: const { + 0: pw.Alignment.centerLeft, + 1: pw.Alignment.centerRight, + 2: pw.Alignment.centerRight, + 3: pw.Alignment.centerRight, + }, ), pw.SizedBox(height: 20), pw.Row( diff --git a/lib/widgets/invoice_pdf_preview_page.dart b/lib/widgets/invoice_pdf_preview_page.dart index 7cf0a13..e3e8395 100644 --- a/lib/widgets/invoice_pdf_preview_page.dart +++ b/lib/widgets/invoice_pdf_preview_page.dart @@ -1,8 +1,13 @@ import 'dart:typed_data'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:printing/printing.dart'; import '../models/invoice_models.dart'; import '../services/pdf_generator.dart'; +import 'package:mailer/mailer.dart'; +import 'package:mailer/smtp_server.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:path_provider/path_provider.dart'; class InvoicePdfPreviewPage extends StatelessWidget { final Invoice invoice; @@ -31,6 +36,58 @@ class InvoicePdfPreviewPage extends StatelessWidget { return Uint8List.fromList(await doc.save()); } + Future _sendEmail(BuildContext context) async { + try { + final prefs = await SharedPreferences.getInstance(); + final host = prefs.getString('smtp_host') ?? ''; + final portStr = prefs.getString('smtp_port') ?? '587'; + final user = prefs.getString('smtp_user') ?? ''; + final pass = prefs.getString('smtp_pass') ?? ''; + final useTls = prefs.getBool('smtp_tls') ?? true; + final bccRaw = prefs.getString('smtp_bcc') ?? ''; + final bccList = bccRaw.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList(); + + if (host.isEmpty || user.isEmpty || pass.isEmpty) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('SMTP設定を先に保存してください'))); + } + return; + } + + final port = int.tryParse(portStr) ?? 587; + final smtpServer = SmtpServer(host, port: port, username: user, password: pass, ignoreBadCertificate: false, ssl: !useTls, allowInsecure: !useTls); + + final toEmail = invoice.contactEmailSnapshot ?? invoice.customer.email; + if (toEmail == null || toEmail.isEmpty) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('送信先メールアドレスがありません(顧客にメールを登録してください)'))); + } + return; + } + + final bytes = await _buildPdfBytes(); + final tempDir = await getTemporaryDirectory(); + final file = File('${tempDir.path}/invoice.pdf'); + await file.writeAsBytes(bytes, flush: true); + final message = Message() + ..from = Address(user) + ..recipients = [toEmail] + ..bccRecipients = bccList + ..subject = '請求書送付' + ..text = '請求書をお送りします。ご確認ください。' + ..attachments = [FileAttachment(file)..fileName = 'invoice.pdf'..contentType = 'application/pdf']; + + await send(message, smtpServer); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('メール送信しました'))); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('メール送信に失敗しました: $e'))); + } + } + } + @override Widget build(BuildContext context) { final isDraft = invoice.isDraft; @@ -86,8 +143,7 @@ class InvoicePdfPreviewPage extends StatelessWidget { child: ElevatedButton.icon( onPressed: showEmail ? () async { - final bytes = await _buildPdfBytes(); - await Printing.sharePdf(bytes: bytes, filename: 'invoice.pdf', subject: '請求書送付'); + await _sendEmail(context); } : null, icon: const Icon(Icons.mail_outline), diff --git a/lib/widgets/slide_to_unlock.dart b/lib/widgets/slide_to_unlock.dart index d5eda64..d4a8f13 100644 --- a/lib/widgets/slide_to_unlock.dart +++ b/lib/widgets/slide_to_unlock.dart @@ -71,7 +71,7 @@ class _SlideToUnlockState extends State { }); }, onHorizontalDragEnd: (details) { - if (_position >= trackWidth * 0.95) { + if (_position >= trackWidth * 0.65) { widget.onUnlocked(); // 成功時はアニメーションで戻すのではなく、状態が変わるのでリセット setState(() => _position = 0); diff --git a/linux/flutter/ephemeral/.plugin_symlinks/shared_preferences_linux b/linux/flutter/ephemeral/.plugin_symlinks/shared_preferences_linux new file mode 120000 index 0000000..963c942 --- /dev/null +++ b/linux/flutter/ephemeral/.plugin_symlinks/shared_preferences_linux @@ -0,0 +1 @@ +/home/user/.pub-cache/hosted/pub.dev/shared_preferences_linux-2.4.1/ \ No newline at end of file diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index d66747f..6bde8d8 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -11,6 +11,7 @@ import mobile_scanner import package_info_plus import printing import share_plus +import shared_preferences_foundation import sqflite_darwin import url_launcher_macos @@ -21,6 +22,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/test/widget_test.dart b/test/widget_test.dart index 1363b77..97c35b7 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -8,7 +8,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:gemi_invoice/main.dart'; +import 'package:h_1/main.dart'; void main() { testWidgets('Counter increments smoke test', (WidgetTester tester) async {