h-1.flutter.0/lib/screens/business_profile_screen.dart
2026-03-04 14:55:40 +09:00

472 lines
18 KiB
Dart

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:image_picker/image_picker.dart';
import '../constants/company_profile_keys.dart';
// NOTE: mail template placeholders may rely on fields edited here.
import '../models/company_model.dart';
import '../services/company_profile_service.dart';
import '../services/company_repository.dart';
import '../widgets/contact_picker_sheet.dart';
import '../widgets/keyboard_inset_wrapper.dart';
class BusinessProfileScreen extends StatefulWidget {
const BusinessProfileScreen({super.key});
@override
State<BusinessProfileScreen> createState() => _BusinessProfileScreenState();
}
class _BusinessProfileScreenState extends State<BusinessProfileScreen> {
final _service = CompanyProfileService();
final _companyRepo = CompanyRepository();
final _companyNameCtrl = TextEditingController();
final _companyZipCtrl = TextEditingController();
final _companyAddrCtrl = TextEditingController();
final _companyTelCtrl = TextEditingController();
final _companyFaxCtrl = TextEditingController();
final _companyEmailCtrl = TextEditingController();
final _companyUrlCtrl = TextEditingController();
final _companyRegCtrl = TextEditingController();
final _staffNameCtrl = TextEditingController();
final _staffEmailCtrl = TextEditingController();
final _staffMobileCtrl = TextEditingController();
final List<_BankControllers> _bankCtrls = List.generate(
kCompanyBankSlotCount,
(_) => _BankControllers(),
);
bool _loading = true;
double _taxRate = 0.10;
String _taxDisplayMode = 'normal';
String? _sealPath;
CompanyInfo? _legacyInfo;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final profile = await _service.loadProfile();
final legacyInfo = await _companyRepo.getCompanyInfo();
if (!mounted) return;
setState(() {
_companyNameCtrl.text = profile.companyName.isNotEmpty ? profile.companyName : legacyInfo.name;
_companyZipCtrl.text = profile.companyZip.isNotEmpty ? profile.companyZip : (legacyInfo.zipCode ?? '');
_companyAddrCtrl.text = profile.companyAddress.isNotEmpty ? profile.companyAddress : (legacyInfo.address ?? '');
_companyTelCtrl.text = profile.companyTel.isNotEmpty ? profile.companyTel : (legacyInfo.tel ?? '');
_companyFaxCtrl.text = profile.companyFax.isNotEmpty ? profile.companyFax : (legacyInfo.fax ?? '');
_companyEmailCtrl.text = profile.companyEmail.isNotEmpty ? profile.companyEmail : (legacyInfo.email ?? '');
_companyUrlCtrl.text = profile.companyUrl.isNotEmpty ? profile.companyUrl : (legacyInfo.url ?? '');
_companyRegCtrl.text = profile.companyReg.isNotEmpty ? profile.companyReg : (legacyInfo.registrationNumber ?? '');
_staffNameCtrl.text = profile.staffName;
_staffEmailCtrl.text = profile.staffEmail;
_staffMobileCtrl.text = profile.staffMobile;
for (var i = 0; i < _bankCtrls.length; i++) {
final ctrl = _bankCtrls[i];
if (i < profile.bankAccounts.length) {
final acc = profile.bankAccounts[i];
ctrl.bankName.text = acc.bankName;
ctrl.branchName.text = acc.branchName;
ctrl.accountType = acc.accountType;
ctrl.accountNumber.text = acc.accountNumber;
ctrl.holderName.text = acc.holderName;
ctrl.isActive = acc.isActive;
}
}
_taxRate = legacyInfo.defaultTaxRate;
_taxDisplayMode = legacyInfo.taxDisplayMode;
_sealPath = legacyInfo.sealPath;
_legacyInfo = legacyInfo;
_loading = false;
});
}
Future<void> _save() async {
final accounts = _bankCtrls
.map(
(c) => CompanyBankAccount(
bankName: c.bankName.text,
branchName: c.branchName.text,
accountType: c.accountType,
accountNumber: c.accountNumber.text,
holderName: c.holderName.text,
isActive: c.isActive,
),
)
.toList();
final activeCount = accounts.where((a) => a.isActive && a.bankName.trim().isNotEmpty).length;
if (activeCount > kCompanyBankActiveLimit) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('振込口座は最大$kCompanyBankActiveLimit件まで有効化できます')),
);
return;
}
final profile = CompanyProfile(
companyName: _companyNameCtrl.text.trim(),
companyZip: _companyZipCtrl.text.trim(),
companyAddress: _companyAddrCtrl.text.trim(),
companyTel: _companyTelCtrl.text.trim(),
companyFax: _companyFaxCtrl.text.trim(),
companyEmail: _companyEmailCtrl.text.trim(),
companyUrl: _companyUrlCtrl.text.trim(),
companyReg: _companyRegCtrl.text.trim(),
staffName: _staffNameCtrl.text.trim(),
staffEmail: _staffEmailCtrl.text.trim(),
staffMobile: _staffMobileCtrl.text.trim(),
bankAccounts: accounts,
);
await _service.saveProfile(profile);
await _companyRepo.saveCompanyInfo(
(_legacyInfo ?? CompanyInfo(name: _companyNameCtrl.text.trim().isEmpty ? '未設定' : _companyNameCtrl.text.trim())).copyWith(
name: _companyNameCtrl.text.trim(),
zipCode: _companyZipCtrl.text.trim(),
address: _companyAddrCtrl.text.trim(),
tel: _companyTelCtrl.text.trim(),
fax: _companyFaxCtrl.text.trim(),
email: _companyEmailCtrl.text.trim(),
url: _companyUrlCtrl.text.trim(),
registrationNumber: _companyRegCtrl.text.trim().isEmpty ? null : _companyRegCtrl.text.trim(),
defaultTaxRate: _taxRate,
taxDisplayMode: _taxDisplayMode,
sealPath: _sealPath,
),
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('自社情報を保存しました')));
}
Future<void> _pickSeal(ImageSource source) async {
final picker = ImagePicker();
final image = await picker.pickImage(source: source, imageQuality: 85);
if (image == null) return;
setState(() {
_sealPath = image.path;
});
}
Future<void> _pickContacts(bool forCompany) async {
final granted = await FlutterContacts.requestPermission(readonly: true);
if (!granted) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('連絡先へのアクセス権限が必要です')));
}
return;
}
final contacts = await FlutterContacts.getContacts(withProperties: true, withAccounts: true);
if (!mounted) return;
final selected = await showModalBottomSheet<Contact?>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => ContactPickerSheet(contacts: contacts, title: forCompany ? '会社情報を電話帳から' : '担当者を電話帳から'),
);
if (selected == null) return;
if (forCompany) {
if (selected.organizations.isNotEmpty) {
_companyNameCtrl.text = selected.organizations.first.company;
} else {
_companyNameCtrl.text = selected.displayName;
}
if (selected.addresses.isNotEmpty) {
final addr = selected.addresses.first;
_companyAddrCtrl.text = [addr.postalCode, addr.state, addr.city, addr.street].where((e) => e.trim().isNotEmpty).join(' ');
}
if (selected.phones.isNotEmpty) {
_companyTelCtrl.text = selected.phones.first.number;
}
if (selected.emails.isNotEmpty) {
_companyEmailCtrl.text = selected.emails.first.address;
}
} else {
_staffNameCtrl.text = selected.displayName;
if (selected.phones.isNotEmpty) {
_staffMobileCtrl.text = selected.phones.first.number;
}
if (selected.emails.isNotEmpty) {
_staffEmailCtrl.text = selected.emails.first.address;
}
}
}
@override
void dispose() {
_companyNameCtrl.dispose();
_companyZipCtrl.dispose();
_companyAddrCtrl.dispose();
_companyTelCtrl.dispose();
_companyFaxCtrl.dispose();
_companyEmailCtrl.dispose();
_companyUrlCtrl.dispose();
_companyRegCtrl.dispose();
_staffNameCtrl.dispose();
_staffEmailCtrl.dispose();
_staffMobileCtrl.dispose();
for (final ctrl in _bankCtrls) {
ctrl.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: const BackButton(),
title: const Text('F2:自社情報'),
backgroundColor: Colors.indigo,
actions: [
IconButton(onPressed: _save, icon: const Icon(Icons.save)),
],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: KeyboardInsetWrapper(
basePadding: const EdgeInsets.all(16),
extraBottom: 24,
child: ListView(
children: [
_section('会社情報', _buildCompanySection()),
_section('担当者情報', _buildStaffSection()),
_section('消費税設定', _buildTaxSection()),
_section('印影(角印)', _buildSealSection()),
_section('振込先口座 (最大2件まで有効)', _buildBankSection()),
],
),
),
);
}
Widget _section(String title, Widget child) {
return Card(
margin: const EdgeInsets.only(bottom: 16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
child,
],
),
),
);
}
Widget _buildCompanySection() {
return Column(
children: [
TextField(controller: _companyNameCtrl, decoration: const InputDecoration(labelText: '自社名')),
const SizedBox(height: 8),
TextField(controller: _companyZipCtrl, decoration: const InputDecoration(labelText: '郵便番号')),
const SizedBox(height: 8),
TextField(controller: _companyAddrCtrl, decoration: const InputDecoration(labelText: '住所')),
const SizedBox(height: 8),
TextField(controller: _companyTelCtrl, decoration: const InputDecoration(labelText: '電話番号')),
const SizedBox(height: 8),
TextField(controller: _companyFaxCtrl, decoration: const InputDecoration(labelText: 'FAX番号')),
const SizedBox(height: 8),
TextField(controller: _companyEmailCtrl, decoration: const InputDecoration(labelText: '代表メールアドレス')),
const SizedBox(height: 8),
TextField(controller: _companyUrlCtrl, decoration: const InputDecoration(labelText: 'URL')),
const SizedBox(height: 8),
TextField(controller: _companyRegCtrl, decoration: const InputDecoration(labelText: '登録番号(T番号)')),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: OutlinedButton.icon(
onPressed: () => _pickContacts(true),
icon: const Icon(Icons.import_contacts),
label: const Text('電話帳から取り込む'),
),
),
],
);
}
Widget _buildStaffSection() {
return Column(
children: [
TextField(controller: _staffNameCtrl, decoration: const InputDecoration(labelText: '担当者名')),
const SizedBox(height: 8),
TextField(controller: _staffEmailCtrl, decoration: const InputDecoration(labelText: '担当者メール')),
const SizedBox(height: 8),
TextField(controller: _staffMobileCtrl, decoration: const InputDecoration(labelText: '担当者携帯番号')),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: OutlinedButton.icon(
onPressed: () => _pickContacts(false),
icon: const Icon(Icons.smartphone),
label: const Text('電話帳から取り込む'),
),
),
],
);
}
Widget _buildBankSection() {
return Column(
children: List.generate(_bankCtrls.length, (index) {
final ctrl = _bankCtrls[index];
return Card(
margin: const EdgeInsets.only(bottom: 12),
color: ctrl.isActive ? Colors.green.shade50 : null,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('口座 ${index + 1}', style: const TextStyle(fontWeight: FontWeight.bold)),
const Spacer(),
Switch(
value: ctrl.isActive,
onChanged: (v) {
setState(() => ctrl.isActive = v);
},
),
],
),
TextField(controller: ctrl.bankName, decoration: const InputDecoration(labelText: '銀行名')),
const SizedBox(height: 8),
TextField(controller: ctrl.branchName, decoration: const InputDecoration(labelText: '支店名')),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
initialValue: ctrl.accountType,
decoration: const InputDecoration(labelText: '種別'),
items: kAccountTypeOptions.map((e) => DropdownMenuItem(value: e, child: Text(e))).toList(),
onChanged: (v) => setState(() => ctrl.accountType = v ?? '普通'),
),
const SizedBox(height: 8),
TextField(controller: ctrl.accountNumber, decoration: const InputDecoration(labelText: '口座番号')),
const SizedBox(height: 8),
TextField(controller: ctrl.holderName, decoration: const InputDecoration(labelText: '名義人')),
],
),
),
);
}),
);
}
Widget _buildTaxSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('デフォルト消費税率', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
ChoiceChip(
label: const Text('10%'),
selected: _taxRate == 0.10,
onSelected: (_) => setState(() => _taxRate = 0.10),
),
ChoiceChip(
label: const Text('8%'),
selected: _taxRate == 0.08,
onSelected: (_) => setState(() => _taxRate = 0.08),
),
ChoiceChip(
label: const Text('0%'),
selected: _taxRate == 0.0,
onSelected: (_) => setState(() => _taxRate = 0.0),
),
],
),
const SizedBox(height: 16),
const Text('消費税の表示設定 (T番号未取得時など)', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
ChoiceChip(
label: const Text('通常表示'),
selected: _taxDisplayMode == 'normal',
onSelected: (_) => setState(() => _taxDisplayMode = 'normal'),
),
ChoiceChip(
label: const Text('表示しない'),
selected: _taxDisplayMode == 'hidden',
onSelected: (_) => setState(() => _taxDisplayMode = 'hidden'),
),
ChoiceChip(
label: const Text('「税別」と表示'),
selected: _taxDisplayMode == 'text_only',
onSelected: (_) => setState(() => _taxDisplayMode = 'text_only'),
),
],
),
],
);
}
Widget _buildSealSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 180,
width: double.infinity,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(12),
color: Colors.grey.shade50,
),
child: _sealPath == null
? const Center(child: Icon(Icons.crop_original, size: 48, color: Colors.grey))
: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.file(File(_sealPath!), fit: BoxFit.contain),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 8,
children: [
OutlinedButton.icon(
onPressed: () => _pickSeal(ImageSource.camera),
icon: const Icon(Icons.camera_alt),
label: const Text('カメラで取り込む'),
),
OutlinedButton.icon(
onPressed: () => _pickSeal(ImageSource.gallery),
icon: const Icon(Icons.photo_library),
label: const Text('アルバムから選択'),
),
],
),
const SizedBox(height: 6),
const Text('白い紙に押した判子を真上から撮影してください', style: TextStyle(fontSize: 12, color: Colors.grey)),
],
);
}
}
class _BankControllers {
final bankName = TextEditingController();
final branchName = TextEditingController();
final accountNumber = TextEditingController();
final holderName = TextEditingController();
String accountType = '普通';
bool isActive = false;
void dispose() {
bankName.dispose();
branchName.dispose();
accountNumber.dispose();
holderName.dispose();
}
}