Work in progress before switching to main

This commit is contained in:
joe 2026-02-26 06:20:30 +09:00
parent bab533fccb
commit 421bcc01f6
18 changed files with 1106 additions and 413 deletions

3
gitremotepush.sh Normal file
View file

@ -0,0 +1,3 @@
git remote add origin git@git.cyberius.biz:joe/h-1.flutter.0.git
git push -u origin main

View file

@ -4,8 +4,8 @@ FLUTTER_APPLICATION_PATH=/home/user/dev/h-1.flutter.0
COCOAPODS_PARALLEL_CODE_SIGN=true COCOAPODS_PARALLEL_CODE_SIGN=true
FLUTTER_TARGET=lib/main.dart FLUTTER_TARGET=lib/main.dart
FLUTTER_BUILD_DIR=build FLUTTER_BUILD_DIR=build
FLUTTER_BUILD_NAME=1.5.0 FLUTTER_BUILD_NAME=1.5.06
FLUTTER_BUILD_NUMBER=150 FLUTTER_BUILD_NUMBER=151
EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386 EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386
EXCLUDED_ARCHS[sdk=iphoneos*]=armv7 EXCLUDED_ARCHS[sdk=iphoneos*]=armv7
DART_OBFUSCATION=false DART_OBFUSCATION=false

View file

@ -5,8 +5,8 @@ export "FLUTTER_APPLICATION_PATH=/home/user/dev/h-1.flutter.0"
export "COCOAPODS_PARALLEL_CODE_SIGN=true" export "COCOAPODS_PARALLEL_CODE_SIGN=true"
export "FLUTTER_TARGET=lib/main.dart" export "FLUTTER_TARGET=lib/main.dart"
export "FLUTTER_BUILD_DIR=build" export "FLUTTER_BUILD_DIR=build"
export "FLUTTER_BUILD_NAME=1.5.0" export "FLUTTER_BUILD_NAME=1.5.06"
export "FLUTTER_BUILD_NUMBER=150" export "FLUTTER_BUILD_NUMBER=151"
export "DART_OBFUSCATION=false" export "DART_OBFUSCATION=false"
export "TRACK_WIDGET_CREATION=true" export "TRACK_WIDGET_CREATION=true"
export "TREE_SHAKE_ICONS=false" export "TREE_SHAKE_ICONS=false"

View file

@ -60,6 +60,15 @@ class MyApp extends StatelessWidget {
useMaterial3: true, useMaterial3: true,
fontFamily: 'IPAexGothic', fontFamily: 'IPAexGothic',
), ),
builder: (context, child) {
return InteractiveViewer(
panEnabled: false,
scaleEnabled: true,
minScale: 0.8,
maxScale: 2.0,
child: child ?? const SizedBox.shrink(),
);
},
home: const InvoiceHistoryScreen(), home: const InvoiceHistoryScreen(),
); );
} }

View file

@ -0,0 +1,47 @@
class CustomerContact {
final String id;
final String customerId;
final String? email;
final String? tel;
final String? address;
final int version;
final bool isActive;
final DateTime createdAt;
CustomerContact({
required this.id,
required this.customerId,
this.email,
this.tel,
this.address,
required this.version,
this.isActive = true,
DateTime? createdAt,
}) : createdAt = createdAt ?? DateTime.now();
factory CustomerContact.fromMap(Map<String, dynamic> map) {
return CustomerContact(
id: map['id'],
customerId: map['customer_id'],
email: map['email'],
tel: map['tel'],
address: map['address'],
version: map['version'],
isActive: (map['is_active'] ?? 0) == 1,
createdAt: DateTime.parse(map['created_at']),
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'customer_id': customerId,
'email': email,
'tel': tel,
'address': address,
'version': version,
'is_active': isActive ? 1 : 0,
'created_at': createdAt.toIso8601String(),
};
}
}

View file

@ -5,8 +5,10 @@ class Customer {
final String formalName; // final String formalName; //
final String title; // 殿 final String title; // 殿
final String? department; // final String? department; //
final String? address; // final String? address; //
final String? tel; // final String? tel; //
final String? email; //
final int? contactVersionId; //
final String? odooId; // Odoo側のID final String? odooId; // Odoo側のID
final bool isSynced; // final bool isSynced; //
final DateTime updatedAt; // final DateTime updatedAt; //
@ -20,6 +22,8 @@ class Customer {
this.department, this.department,
this.address, this.address,
this.tel, this.tel,
this.email,
this.contactVersionId,
this.odooId, this.odooId,
this.isSynced = false, this.isSynced = false,
DateTime? updatedAt, DateTime? updatedAt,
@ -43,6 +47,7 @@ class Customer {
'department': department, 'department': department,
'address': address, 'address': address,
'tel': tel, 'tel': tel,
'contact_version_id': contactVersionId,
'odoo_id': odooId, 'odoo_id': odooId,
'is_locked': isLocked ? 1 : 0, 'is_locked': isLocked ? 1 : 0,
'is_synced': isSynced ? 1 : 0, 'is_synced': isSynced ? 1 : 0,
@ -57,8 +62,10 @@ class Customer {
formalName: map['formal_name'], formalName: map['formal_name'],
title: map['title'] ?? "", title: map['title'] ?? "",
department: map['department'], department: map['department'],
address: map['address'], address: map['contact_address'] ?? map['address'],
tel: map['tel'], tel: map['contact_tel'] ?? map['tel'],
email: map['contact_email'],
contactVersionId: map['contact_version_id'],
odooId: map['odoo_id'], odooId: map['odoo_id'],
isLocked: (map['is_locked'] ?? 0) == 1, isLocked: (map['is_locked'] ?? 0) == 1,
isSynced: map['is_synced'] == 1, isSynced: map['is_synced'] == 1,
@ -78,6 +85,8 @@ class Customer {
bool? isSynced, bool? isSynced,
DateTime? updatedAt, DateTime? updatedAt,
bool? isLocked, bool? isLocked,
String? email,
int? contactVersionId,
}) { }) {
return Customer( return Customer(
id: id ?? this.id, id: id ?? this.id,
@ -87,6 +96,8 @@ class Customer {
department: department ?? this.department, department: department ?? this.department,
address: address ?? this.address, address: address ?? this.address,
tel: tel ?? this.tel, tel: tel ?? this.tel,
email: email ?? this.email,
contactVersionId: contactVersionId ?? this.contactVersionId,
odooId: odooId ?? this.odooId, odooId: odooId ?? this.odooId,
isSynced: isSynced ?? this.isSynced, isSynced: isSynced ?? this.isSynced,
updatedAt: updatedAt ?? this.updatedAt, updatedAt: updatedAt ?? this.updatedAt,

View file

@ -84,6 +84,10 @@ class Invoice {
final bool isDraft; // : final bool isDraft; // :
final String? subject; // : final String? subject; // :
final bool isLocked; // : final bool isLocked; // :
final int? contactVersionId; // :
final String? contactEmailSnapshot;
final String? contactTelSnapshot;
final String? contactAddressSnapshot;
Invoice({ Invoice({
String? id, String? id,
@ -104,6 +108,10 @@ class Invoice {
this.isDraft = false, // : this.isDraft = false, // :
this.subject, // : this.subject, // :
this.isLocked = false, this.isLocked = false,
this.contactVersionId,
this.contactEmailSnapshot,
this.contactTelSnapshot,
this.contactAddressSnapshot,
}) : id = id ?? DateTime.now().millisecondsSinceEpoch.toString(), }) : id = id ?? DateTime.now().millisecondsSinceEpoch.toString(),
terminalId = terminalId ?? "T1", // ID terminalId = terminalId ?? "T1", // ID
updatedAt = updatedAt ?? DateTime.now(); updatedAt = updatedAt ?? DateTime.now();
@ -163,6 +171,10 @@ class Invoice {
'is_draft': isDraft ? 1 : 0, // 'is_draft': isDraft ? 1 : 0, //
'subject': subject, // 'subject': subject, //
'is_locked': isLocked ? 1 : 0, 'is_locked': isLocked ? 1 : 0,
'contact_version_id': contactVersionId,
'contact_email_snapshot': contactEmailSnapshot,
'contact_tel_snapshot': contactTelSnapshot,
'contact_address_snapshot': contactAddressSnapshot,
}; };
} }
@ -185,6 +197,10 @@ class Invoice {
bool? isDraft, bool? isDraft,
String? subject, String? subject,
bool? isLocked, bool? isLocked,
int? contactVersionId,
String? contactEmailSnapshot,
String? contactTelSnapshot,
String? contactAddressSnapshot,
}) { }) {
return Invoice( return Invoice(
id: id ?? this.id, id: id ?? this.id,
@ -205,6 +221,10 @@ class Invoice {
isDraft: isDraft ?? this.isDraft, isDraft: isDraft ?? this.isDraft,
subject: subject ?? this.subject, subject: subject ?? this.subject,
isLocked: isLocked ?? this.isLocked, isLocked: isLocked ?? this.isLocked,
contactVersionId: contactVersionId ?? this.contactVersionId,
contactEmailSnapshot: contactEmailSnapshot ?? this.contactEmailSnapshot,
contactTelSnapshot: contactTelSnapshot ?? this.contactTelSnapshot,
contactAddressSnapshot: contactAddressSnapshot ?? this.contactAddressSnapshot,
); );
} }

View file

@ -24,6 +24,47 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
_loadCustomers(); _loadCustomers();
} }
Future<void> _showContactUpdateDialog(Customer customer) async {
final emailController = TextEditingController(text: customer.email ?? "");
final telController = TextEditingController(text: customer.tel ?? "");
final addressController = TextEditingController(text: customer.address ?? "");
final updated = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('連絡先を更新'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: emailController, decoration: const InputDecoration(labelText: 'メール')),
TextField(controller: telController, decoration: const InputDecoration(labelText: '電話番号'), keyboardType: TextInputType.phone),
TextField(controller: addressController, decoration: const InputDecoration(labelText: '住所')),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('キャンセル')),
ElevatedButton(
onPressed: () async {
await _customerRepo.updateContact(
customerId: customer.id,
email: emailController.text.isEmpty ? null : emailController.text,
tel: telController.text.isEmpty ? null : telController.text,
address: addressController.text.isEmpty ? null : addressController.text,
);
if (!mounted) return;
Navigator.pop(context, true);
},
child: const Text('保存'),
),
],
),
);
if (updated == true) {
_loadCustomers();
}
}
Future<void> _loadCustomers() async { Future<void> _loadCustomers() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
final customers = await _customerRepo.getAllCustomers(); final customers = await _customerRepo.getAllCustomers();
@ -130,6 +171,165 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
} }
} }
Future<void> _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'],
},
];
String selectedEntryId = '0';
String selectedNameSource = 'company';
int selectedAddressIndex = 0;
int selectedEmailIndex = 0;
final imported = await showDialog<Customer>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) {
final entry = phonebook[int.parse(selectedEntryId)];
final addresses = (entry['addresses'] as List<String>);
final emails = (entry['emails'] as List<String>);
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('電話帳から取り込む'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<String>(
value: selectedEntryId,
decoration: const InputDecoration(labelText: '電話帳エントリ'),
items: phonebook
.asMap()
.entries
.map((e) => DropdownMenuItem(value: e.key.toString(), child: Text(e.value['company'] as String)))
.toList(),
onChanged: (v) {
setDialogState(() {
selectedEntryId = v ?? '0';
selectedAddressIndex = 0;
});
},
),
const SizedBox(height: 8),
const Text('顧客名の取り込み元'),
Row(
children: [
Expanded(
child: RadioListTile<String>(
dense: true,
title: const Text('会社名'),
value: 'company',
groupValue: selectedNameSource,
onChanged: (v) => setDialogState(() => selectedNameSource = v ?? 'company'),
),
),
Expanded(
child: RadioListTile<String>(
dense: true,
title: const Text('氏名'),
value: 'person',
groupValue: selectedNameSource,
onChanged: (v) => setDialogState(() => selectedNameSource = v ?? 'person'),
),
),
],
),
const SizedBox(height: 8),
DropdownButtonFormField<int>(
value: selectedAddressIndex,
decoration: const InputDecoration(labelText: '住所を選択'),
items: addresses
.asMap()
.entries
.map((e) => DropdownMenuItem(value: e.key, child: Text(e.value)))
.toList(),
onChanged: (v) => setDialogState(() => selectedAddressIndex = v ?? 0),
),
const SizedBox(height: 8),
DropdownButtonFormField<int>(
value: selectedEmailIndex,
decoration: const InputDecoration(labelText: 'メールを選択'),
items: emails
.asMap()
.entries
.map((e) => DropdownMenuItem(value: e.key, child: Text(e.value)))
.toList(),
onChanged: (v) => setDialogState(() => selectedEmailIndex = v ?? 0),
),
const SizedBox(height: 12),
TextField(
controller: displayController,
decoration: const InputDecoration(labelText: '表示名(編集可)'),
),
TextField(
controller: formalController,
decoration: const InputDecoration(labelText: '正式名称(編集可)'),
),
TextField(
controller: addressController,
decoration: const InputDecoration(labelText: '住所(編集可)'),
),
TextField(
controller: emailController,
decoration: const InputDecoration(labelText: 'メール(編集可)'),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')),
ElevatedButton(
onPressed: () {
final newCustomer = Customer(
id: const Uuid().v4(),
displayName: displayController.text,
formalName: formalController.text,
title: selectedNameSource == 'company' ? '御中' : '',
address: addressController.text,
tel: entry['tel'] as String?,
email: emailController.text.isEmpty ? null : emailController.text,
isSynced: false,
);
Navigator.pop(context, newCustomer);
},
child: const Text('取り込む'),
),
],
);
},
),
);
if (imported != null) {
await _customerRepo.saveCustomer(imported);
_loadCustomers();
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -210,10 +410,12 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
), ),
], ],
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton.extended(
onPressed: () => _addOrEditCustomer(), onPressed: _showPhonebookImport,
child: const Icon(Icons.person_add), icon: const Icon(Icons.add),
label: const Text('電話帳から取り込む'),
backgroundColor: Colors.indigo, backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
), ),
); );
} }
@ -250,19 +452,31 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
const SizedBox(height: 8), const SizedBox(height: 8),
if (c.address != null) Text("住所: ${c.address}") else const SizedBox.shrink(), if (c.address != null) Text("住所: ${c.address}") else const SizedBox.shrink(),
if (c.tel != null) Text("TEL: ${c.tel}") else const SizedBox.shrink(), if (c.tel != null) Text("TEL: ${c.tel}") else const SizedBox.shrink(),
if (c.email != null) Text("メール: ${c.email}") else const SizedBox.shrink(),
Text("敬称: ${c.title}"), Text("敬称: ${c.title}"),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
children: [ children: [
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () { onPressed: c.isLocked
Navigator.pop(context); ? null
_addOrEditCustomer(customer: c); : () {
}, Navigator.pop(context);
_addOrEditCustomer(customer: c);
},
icon: const Icon(Icons.edit), icon: const Icon(Icons.edit),
label: const Text("編集"), label: const Text("編集"),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () {
Navigator.pop(context);
_showContactUpdateDialog(c);
},
icon: const Icon(Icons.contact_mail),
label: const Text("連絡先を更新"),
),
const SizedBox(width: 8),
if (!c.isLocked) if (!c.isLocked)
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () async { onPressed: () async {

View file

@ -3,6 +3,9 @@ import 'invoice_input_screen.dart'; // Add this line
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:open_filex/open_filex.dart'; import 'package:open_filex/open_filex.dart';
import 'package:printing/printing.dart';
import 'dart:typed_data';
import '../widgets/invoice_pdf_preview_page.dart';
import '../models/invoice_models.dart'; import '../models/invoice_models.dart';
import '../services/pdf_generator.dart'; import '../services/pdf_generator.dart';
import '../services/invoice_repository.dart'; import '../services/invoice_repository.dart';
@ -34,6 +37,7 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
final _customerRepo = CustomerRepository(); final _customerRepo = CustomerRepository();
final _companyRepo = CompanyRepository(); final _companyRepo = CompanyRepository();
CompanyInfo? _companyInfo; CompanyInfo? _companyInfo;
bool _showFormalWarning = true;
@override @override
void initState() { void initState() {
@ -147,8 +151,8 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final fmt = NumberFormat("#,###"); final fmt = NumberFormat("#,###");
final isDraft = _currentInvoice.isDraft; final isDraft = _currentInvoice.isDraft;
final themeColor = isDraft ? Colors.blueGrey.shade800 : Colors.white; final themeColor = Colors.white; //
final textColor = isDraft ? Colors.white : Colors.black87; final textColor = Colors.black87;
final locked = _currentInvoice.isLocked; final locked = _currentInvoice.isLocked;
@ -156,24 +160,37 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
backgroundColor: themeColor, backgroundColor: themeColor,
appBar: AppBar( appBar: AppBar(
leading: const BackButton(), // leading: const BackButton(), //
title: Text(isDraft ? "伝票詳細 (下書き)" : "販売アシスト1号 伝票詳細"), title: Row(
backgroundColor: isDraft ? Colors.black87 : Colors.blueGrey, mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
isDraft ? "伝票詳細" : "販売アシスト1号 伝票詳細",
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
if (isDraft)
Chip(
label: const Text("下書き", style: TextStyle(color: Colors.white)),
backgroundColor: Colors.orange,
padding: EdgeInsets.zero,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
],
),
backgroundColor: Colors.indigo.shade700,
actions: [ actions: [
if (locked) if (locked)
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
child: Chip( child: Chip(
label: const Text("ロック中", style: TextStyle(color: Colors.white)), label: const Text("確定済み", style: TextStyle(color: Colors.white)),
avatar: const Icon(Icons.lock, size: 16, color: Colors.white), avatar: const Icon(Icons.lock, size: 16, color: Colors.white),
backgroundColor: Colors.redAccent, backgroundColor: Colors.redAccent,
), ),
), ),
if (isDraft && !_isEditing)
TextButton.icon(
icon: const Icon(Icons.check_circle_outline, color: Colors.orangeAccent),
label: const Text("正式発行", style: TextStyle(color: Colors.orangeAccent)),
onPressed: _showPromoteDialog,
),
if (!_isEditing) ...[ if (!_isEditing) ...[
IconButton(icon: const Icon(Icons.grid_on), onPressed: _exportCsv, tooltip: "CSV出力"), IconButton(icon: const Icon(Icons.grid_on), onPressed: _exportCsv, tooltip: "CSV出力"),
if (widget.isUnlocked && !locked) if (widget.isUnlocked && !locked)
@ -198,34 +215,31 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
); );
}, },
), ),
if (widget.isUnlocked && !locked) IconButton(
IconButton( icon: const Icon(Icons.edit_note, color: Colors.white),
icon: const Icon(Icons.edit_note), tooltip: locked
tooltip: "詳細編集", ? "ロック中"
onPressed: () async { : (widget.isUnlocked ? "詳細編集" : "アンロックして編集"),
await Navigator.push( onPressed: (locked || !widget.isUnlocked)
context, ? null
MaterialPageRoute( : () async {
builder: (context) => InvoiceInputForm( await Navigator.push(
onInvoiceGenerated: (inv, path) {}, context,
existingInvoice: _currentInvoice, MaterialPageRoute(
), builder: (context) => InvoiceInputForm(
), onInvoiceGenerated: (inv, path) {},
); existingInvoice: _currentInvoice,
final repo = InvoiceRepository(); ),
final customerRepo = CustomerRepository(); ),
final customers = await customerRepo.getAllCustomers(); );
final updated = (await repo.getAllInvoices(customers)).firstWhere((i) => i.id == _currentInvoice.id, orElse: () => _currentInvoice); final repo = InvoiceRepository();
setState(() => _currentInvoice = updated); final customerRepo = CustomerRepository();
}, final customers = await customerRepo.getAllCustomers();
), final updated = (await repo.getAllInvoices(customers)).firstWhere((i) => i.id == _currentInvoice.id, orElse: () => _currentInvoice);
setState(() => _currentInvoice = updated);
},
),
] else ...[ ] else ...[
if (isDraft)
TextButton.icon(
icon: const Icon(Icons.check_circle_outline, color: Colors.orangeAccent),
label: const Text("正式発行", style: TextStyle(color: Colors.orangeAccent)),
onPressed: _showPromoteDialog,
),
IconButton(icon: const Icon(Icons.save), onPressed: _saveChanges), IconButton(icon: const Icon(Icons.save), onPressed: _saveChanges),
IconButton(icon: const Icon(Icons.cancel), onPressed: () => setState(() => _isEditing = false)), IconButton(icon: const Icon(Icons.cancel), onPressed: () => setState(() => _isEditing = false)),
] ]
@ -236,6 +250,29 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (isDraft)
Container(
width: double.infinity,
padding: const EdgeInsets.all(8),
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.shade200),
),
child: Row(
children: const [
Icon(Icons.edit_note, color: Colors.orange),
SizedBox(width: 8),
Expanded(
child: Text(
"下書き: 未確定・PDFは正式発行で確定",
style: TextStyle(color: Colors.orange),
),
),
],
),
),
_buildHeaderSection(textColor), _buildHeaderSection(textColor),
if (_isEditing) ...[ if (_isEditing) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
@ -243,7 +280,7 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
const SizedBox(height: 16), const SizedBox(height: 16),
_buildExperimentalSection(isDraft), _buildExperimentalSection(isDraft),
], ],
Divider(height: 32, color: isDraft ? Colors.white70 : Colors.grey), Divider(height: 32, color: Colors.grey.shade400),
Text("明細一覧", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: textColor)), Text("明細一覧", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: textColor)),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildItemTable(fmt, textColor, isDraft), _buildItemTable(fmt, textColor, isDraft),
@ -333,22 +370,12 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
], ],
if (_currentInvoice.customer.department != null && _currentInvoice.customer.department!.isNotEmpty) if (_currentInvoice.customer.department != null && _currentInvoice.customer.department!.isNotEmpty)
Text(_currentInvoice.customer.department!, style: TextStyle(fontSize: 16, color: textColor)), Text(_currentInvoice.customer.department!, style: TextStyle(fontSize: 16, color: textColor)),
if (_currentInvoice.latitude != null) ...[ if ((_currentInvoice.contactAddressSnapshot ?? _currentInvoice.customer.address) != null)
const SizedBox(height: 4), Text("住所: ${_currentInvoice.contactAddressSnapshot ?? _currentInvoice.customer.address}", style: TextStyle(color: textColor)),
Padding( if ((_currentInvoice.contactTelSnapshot ?? _currentInvoice.customer.tel) != null)
padding: const EdgeInsets.only(top: 4.0), Text("TEL: ${_currentInvoice.contactTelSnapshot ?? _currentInvoice.customer.tel}", style: TextStyle(color: textColor)),
child: Row( if ((_currentInvoice.contactEmailSnapshot ?? _currentInvoice.customer.email) != null)
children: [ Text("メール: ${_currentInvoice.contactEmailSnapshot ?? _currentInvoice.customer.email}", style: TextStyle(color: textColor)),
const Icon(Icons.location_on, size: 14, color: Colors.blueGrey),
const SizedBox(width: 4),
Text(
"座標: ${_currentInvoice.latitude!.toStringAsFixed(4)}, ${_currentInvoice.longitude!.toStringAsFixed(4)}",
style: const TextStyle(fontSize: 12, color: Colors.blueGrey),
),
],
),
),
],
if (_currentInvoice.notes?.isNotEmpty ?? false) ...[ if (_currentInvoice.notes?.isNotEmpty ?? false) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Text("備考: ${_currentInvoice.notes}", style: TextStyle(color: textColor.withOpacity(0.9))), Text("備考: ${_currentInvoice.notes}", style: TextStyle(color: textColor.withOpacity(0.9))),
@ -498,12 +525,20 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
} }
Widget _buildFooterActions() { Widget _buildFooterActions() {
if (_isEditing || _currentFilePath == null) return const SizedBox(); if (_isEditing) return const SizedBox();
return Row( return Row(
children: [ children: [
Expanded( Expanded(
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: _openPdf, onPressed: _previewPdf,
icon: const Icon(Icons.picture_as_pdf),
label: const Text("PDFプレビュー"),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: _currentFilePath != null ? _openPdf : null,
icon: const Icon(Icons.launch), icon: const Icon(Icons.launch),
label: const Text("PDFを開く"), label: const Text("PDFを開く"),
style: ElevatedButton.styleFrom(backgroundColor: Colors.orange, foregroundColor: Colors.white), style: ElevatedButton.styleFrom(backgroundColor: Colors.orange, foregroundColor: Colors.white),
@ -512,7 +547,7 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: _sharePdf, onPressed: _currentFilePath != null ? _sharePdf : null,
icon: const Icon(Icons.share), icon: const Icon(Icons.share),
label: const Text("共有"), label: const Text("共有"),
style: ElevatedButton.styleFrom(backgroundColor: Colors.green, foregroundColor: Colors.white), style: ElevatedButton.styleFrom(backgroundColor: Colors.green, foregroundColor: Colors.white),
@ -523,19 +558,54 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
} }
Future<void> _showPromoteDialog() async { Future<void> _showPromoteDialog() async {
bool showWarning = _showFormalWarning;
final confirm = await showDialog<bool>( final confirm = await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => StatefulBuilder(
title: const Text("正式発行"), builder: (context, setStateDialog) {
content: const Text("この下書き伝票を「確定」として正式に発行しますか?\n(下書きモードが解除され、通常の背景に戻ります)"), return AlertDialog(
actions: [ title: const Text("正式発行"),
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("キャンセル")), content: Column(
ElevatedButton( mainAxisSize: MainAxisSize.min,
onPressed: () => Navigator.pop(context, true), crossAxisAlignment: CrossAxisAlignment.start,
style: ElevatedButton.styleFrom(backgroundColor: Colors.orange), children: [
child: const Text("正式発行する"), const Text("この下書き伝票を「確定」として正式に発行しますか?"),
), const SizedBox(height: 8),
], if (showWarning)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.redAccent, width: 1),
),
child: const Text(
"確定すると暗号チェーンシステムに組み込まれ、二度と編集できません。内容を最終確認のうえ実行してください。",
style: TextStyle(color: Colors.redAccent, fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text("警告文を表示"),
value: showWarning,
onChanged: (val) {
setStateDialog(() => showWarning = val);
setState(() => _showFormalWarning = val);
},
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("キャンセル")),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(backgroundColor: Colors.orange),
child: const Text("正式発行する"),
),
],
);
},
), ),
); );
@ -583,6 +653,32 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
await Share.shareXFiles([XFile(_currentFilePath!)], text: '請求書送付'); await Share.shareXFiles([XFile(_currentFilePath!)], text: '請求書送付');
} }
} }
Future<Uint8List> _buildPdfBytes() async {
final doc = await buildInvoiceDocument(_currentInvoice);
return Uint8List.fromList(await doc.save());
}
Future<void> _previewPdf() async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => InvoicePdfPreviewPage(
invoice: _currentInvoice,
isUnlocked: widget.isUnlocked,
isLocked: _currentInvoice.isLocked,
allowFormalIssue: true,
onFormalIssue: () async {
await _showPromoteDialog();
return !_currentInvoice.isDraft;
},
showShare: true,
showEmail: true,
showPrint: true,
),
),
);
}
} }
class _TableCell extends StatelessWidget { class _TableCell extends StatelessWidget {

View file

@ -4,15 +4,19 @@ import '../models/invoice_models.dart';
import '../models/customer_model.dart'; import '../models/customer_model.dart';
import '../services/invoice_repository.dart'; import '../services/invoice_repository.dart';
import '../services/customer_repository.dart'; import '../services/customer_repository.dart';
import '../services/pdf_generator.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 'product_master_screen.dart';
import 'customer_master_screen.dart'; import 'customer_master_screen.dart';
import 'invoice_input_screen.dart';
import 'settings_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
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:printing/printing.dart';
import '../widgets/invoice_pdf_preview_page.dart';
class InvoiceHistoryScreen extends StatefulWidget { class InvoiceHistoryScreen extends StatefulWidget {
const InvoiceHistoryScreen({Key? key}) : super(key: key); const InvoiceHistoryScreen({Key? key}) : super(key: key);
@ -41,6 +45,96 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
_loadVersion(); _loadVersion();
} }
Future<void> _showInvoiceActions(Invoice invoice) async {
if (invoice.isLocked) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("ロック中の伝票は操作できません")));
return;
}
if (!_isUnlocked) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("操作するにはアンロックが必要です")));
return;
}
await showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.picture_as_pdf),
title: const Text("PDFプレビュー"),
onTap: () async {
Navigator.pop(context);
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => InvoicePdfPreviewPage(
invoice: invoice,
isUnlocked: _isUnlocked,
isLocked: invoice.isLocked,
allowFormalIssue: !invoice.isLocked,
onFormalIssue: () async {
final repo = InvoiceRepository();
final promoted = invoice.copyWith(isDraft: false);
await repo.updateInvoice(promoted);
_loadData();
return true;
},
showShare: true,
showEmail: true,
showPrint: true,
),
),
);
_loadData();
},
),
ListTile(
leading: const Icon(Icons.edit),
title: const Text("編集"),
onTap: () async {
Navigator.pop(context);
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => InvoiceInputForm(
existingInvoice: invoice,
onInvoiceGenerated: (inv, path) {},
),
),
);
_loadData();
},
),
ListTile(
leading: const Icon(Icons.delete, color: Colors.redAccent),
title: const Text("削除", style: TextStyle(color: Colors.redAccent)),
onTap: () async {
Navigator.pop(context);
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text("伝票の削除"),
content: Text("${invoice.customerNameForDisplay}」の伝票(${invoice.invoiceNumber})を削除しますか?\nこの操作は取り消せません。"),
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 _invoiceRepo.deleteInvoice(invoice.id);
_loadData();
}
},
),
],
),
),
);
}
Future<void> _loadVersion() async { Future<void> _loadVersion() async {
final packageInfo = await PackageInfo.fromPlatform(); final packageInfo = await PackageInfo.fromPlatform();
setState(() { setState(() {
@ -223,45 +317,6 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
), ),
), ),
), ),
drawer: Drawer(
child: ListView(
padding: EdgeInsets.zero,
children: [
const DrawerHeader(
decoration: BoxDecoration(color: Colors.blueGrey),
child: Text("販売アシスト1号", style: TextStyle(color: Colors.white, fontSize: 24)),
),
ListTile(
leading: const Icon(Icons.history),
title: const Text("伝票マスター一覧"),
onTap: () => Navigator.pop(context),
),
ListTile(
leading: const Icon(Icons.add_task),
title: const Text("新規伝票作成"),
onTap: () {
Navigator.pop(context);
Navigator.push(
context,
MaterialPageRoute(builder: (context) => InvoiceFlowScreen(onComplete: _loadData)),
);
},
),
const Divider(),
ListTile(
leading: const Icon(Icons.admin_panel_settings),
title: const Text("マスター管理・同期"),
onTap: () {
Navigator.pop(context);
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const ManagementScreen()),
);
},
),
],
),
),
body: Column( body: Column(
children: [ children: [
Padding( Padding(
@ -327,17 +382,43 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
], ],
), ),
subtitle: Text("${dateFormatter.format(invoice.date)} - ${invoice.invoiceNumber}"), subtitle: Text("${dateFormatter.format(invoice.date)} - ${invoice.invoiceNumber}"),
trailing: Column( trailing: SizedBox(
mainAxisAlignment: MainAxisAlignment.center, height: 56,
crossAxisAlignment: CrossAxisAlignment.end, child: Column(
children: [ mainAxisSize: MainAxisSize.min,
Text("${amountFormatter.format(invoice.totalAmount)}", mainAxisAlignment: MainAxisAlignment.center,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), crossAxisAlignment: CrossAxisAlignment.end,
if (invoice.isSynced) children: [
const Icon(Icons.sync, size: 16, color: Colors.green) Text("${amountFormatter.format(invoice.totalAmount)}",
else style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
const Icon(Icons.sync_disabled, size: 16, color: Colors.orange), const SizedBox(height: 2),
], if (invoice.isSynced)
const Icon(Icons.sync, size: 14, color: Colors.green)
else
const Icon(Icons.sync_disabled, size: 14, color: Colors.orange),
const SizedBox(height: 4),
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(width: 32, height: 28),
icon: const Icon(Icons.edit, size: 18),
tooltip: invoice.isLocked ? "ロック中" : (_isUnlocked ? "編集" : "アンロックして編集"),
onPressed: (invoice.isLocked || !_isUnlocked)
? null
: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => InvoiceInputForm(
existingInvoice: invoice,
onInvoiceGenerated: (inv, path) {},
),
),
);
_loadData();
},
),
],
),
), ),
onTap: () async { onTap: () async {
await Navigator.push( await Navigator.push(
@ -351,36 +432,7 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
); );
_loadData(); _loadData();
}, },
onLongPress: () async { onLongPress: () => _showInvoiceActions(invoice),
if (invoice.isLocked) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("ロック中の伝票は削除できません")));
return;
}
if (!_isUnlocked) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("削除するにはアンロックが必要です")),
);
return;
}
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text("伝票の削除"),
content: Text("${invoice.customerNameForDisplay}」の伝票(${invoice.invoiceNumber})を削除しますか?\nこの操作は取り消せません。"),
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 _invoiceRepo.deleteInvoice(invoice.id);
_loadData();
}
},
); );
}, },
), ),

View file

@ -6,7 +6,8 @@ import '../models/invoice_models.dart';
import '../services/pdf_generator.dart'; import '../services/pdf_generator.dart';
import '../services/invoice_repository.dart'; import '../services/invoice_repository.dart';
import '../services/customer_repository.dart'; import '../services/customer_repository.dart';
import 'package:printing/printing.dart'; import '../widgets/invoice_pdf_preview_page.dart';
import 'invoice_detail_page.dart';
import '../services/gps_service.dart'; import '../services/gps_service.dart';
import 'customer_picker_modal.dart'; import 'customer_picker_modal.dart';
import 'product_picker_modal.dart'; import 'product_picker_modal.dart';
@ -170,32 +171,39 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : "(非課税)", notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : "(非課税)",
); );
showDialog( Navigator.push(
context: context, context,
builder: (context) => Dialog.fullscreen( MaterialPageRoute(
child: Column( builder: (context) => InvoicePdfPreviewPage(
children: [ invoice: invoice,
AppBar( isUnlocked: true,
title: Text("${invoice.documentTypeName}プレビュー"), isLocked: false,
leading: IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)), allowFormalIssue: widget.existingInvoice != null && !(widget.existingInvoice?.isLocked ?? false),
), onFormalIssue: (widget.existingInvoice != null)
Expanded( ? () async {
child: PdfPreview( final promoted = invoice.copyWith(isDraft: false);
build: (format) async { await _invoiceRepo.saveInvoice(promoted);
// PdfGeneratorを少しリファクタして pw.Document final newPath = await generateInvoicePdf(promoted);
// generateInvoicePdf final saved = newPath != null ? promoted.copyWith(filePath: newPath) : promoted;
// ( generateInvoicePdf ) await _invoiceRepo.saveInvoice(saved);
// Generatorを修正する if (!mounted) return false;
// Generator pw.Document Navigator.pop(context); // close preview
final pdfDoc = await buildInvoiceDocument(invoice); Navigator.pop(context); // exit edit screen
return pdfDoc.save(); await Navigator.push(
}, context,
allowPrinting: false, MaterialPageRoute(
allowSharing: false, builder: (_) => InvoiceDetailPage(
canChangePageFormat: false, invoice: saved,
), isUnlocked: true,
), ),
], ),
);
return true;
}
: null,
showShare: false,
showEmail: false,
showPrint: false,
), ),
), ),
); );
@ -223,8 +231,6 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildDocumentTypeSection(),
const SizedBox(height: 16),
_buildDateSection(), _buildDateSection(),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildCustomerSection(), _buildCustomerSection(),

View file

@ -3,6 +3,7 @@ import '../models/customer_model.dart';
import 'database_helper.dart'; import 'database_helper.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import 'activity_log_repository.dart'; import 'activity_log_repository.dart';
import '../models/customer_contact.dart';
class CustomerRepository { class CustomerRepository {
final DatabaseHelper _dbHelper = DatabaseHelper(); final DatabaseHelper _dbHelper = DatabaseHelper();
@ -10,7 +11,12 @@ class CustomerRepository {
Future<List<Customer>> getAllCustomers() async { Future<List<Customer>> getAllCustomers() async {
final db = await _dbHelper.database; final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query('customers', orderBy: 'display_name ASC'); final List<Map<String, dynamic>> 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) { if (maps.isEmpty) {
await _generateSampleCustomers(); await _generateSampleCustomers();
@ -40,11 +46,14 @@ class CustomerRepository {
Future<void> saveCustomer(Customer customer) async { Future<void> saveCustomer(Customer customer) async {
final db = await _dbHelper.database; final db = await _dbHelper.database;
await db.insert( await db.transaction((txn) async {
'customers', await txn.insert(
customer.toMap(), 'customers',
conflictAlgorithm: ConflictAlgorithm.replace, customer.toMap(),
); conflictAlgorithm: ConflictAlgorithm.replace,
);
await _upsertActiveContact(txn, customer);
});
await _logRepo.logAction( await _logRepo.logAction(
action: "SAVE_CUSTOMER", action: "SAVE_CUSTOMER",
@ -109,13 +118,67 @@ class CustomerRepository {
Future<List<Customer>> searchCustomers(String query) async { Future<List<Customer>> searchCustomers(String query) async {
final db = await _dbHelper.database; final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query( final List<Map<String, dynamic>> maps = await db.rawQuery('''
'customers', SELECT c.*, cc.address AS contact_address, cc.tel AS contact_tel, cc.email AS contact_email
where: 'display_name LIKE ? OR formal_name LIKE ?', FROM customers c
whereArgs: ['%$query%', '%$query%'], LEFT JOIN customer_contacts cc ON cc.customer_id = c.id AND cc.is_active = 1
orderBy: 'display_name ASC', WHERE c.display_name LIKE ? OR c.formal_name LIKE ?
limit: 50, ORDER BY c.display_name ASC
); LIMIT 50
''', ['%$query%', '%$query%']);
return List.generate(maps.length, (i) => Customer.fromMap(maps[i])); return List.generate(maps.length, (i) => Customer.fromMap(maps[i]));
} }
Future<void> updateContact({required String customerId, String? email, String? tel, String? address}) async {
final db = await _dbHelper.database;
await db.transaction((txn) async {
final nextVersion = await _nextContactVersion(txn, customerId);
await txn.update('customer_contacts', {'is_active': 0}, where: 'customer_id = ?', whereArgs: [customerId]);
await txn.insert('customer_contacts', {
'id': const Uuid().v4(),
'customer_id': customerId,
'email': email,
'tel': tel,
'address': address,
'version': nextVersion,
'is_active': 1,
'created_at': DateTime.now().toIso8601String(),
});
});
await _logRepo.logAction(
action: "UPDATE_CUSTOMER_CONTACT",
targetType: "CUSTOMER",
targetId: customerId,
details: "連絡先を更新 (version up)",
);
}
Future<CustomerContact?> getActiveContact(String customerId) async {
final db = await _dbHelper.database;
final rows = await db.query('customer_contacts', where: 'customer_id = ? AND is_active = 1', whereArgs: [customerId], limit: 1);
if (rows.isEmpty) return null;
return CustomerContact.fromMap(rows.first);
}
Future<int> _nextContactVersion(DatabaseExecutor txn, String customerId) async {
final res = await txn.rawQuery('SELECT MAX(version) as v FROM customer_contacts WHERE customer_id = ?', [customerId]);
final current = res.first['v'] as int?;
return (current ?? 0) + 1;
}
Future<void> _upsertActiveContact(DatabaseExecutor txn, Customer customer) async {
final nextVersion = await _nextContactVersion(txn, customer.id);
await txn.update('customer_contacts', {'is_active': 0}, where: 'customer_id = ?', whereArgs: [customer.id]);
await txn.insert('customer_contacts', {
'id': const Uuid().v4(),
'customer_id': customer.id,
'email': customer.email,
'tel': customer.tel,
'address': customer.address,
'version': nextVersion,
'is_active': 1,
'created_at': DateTime.now().toIso8601String(),
});
}
} }

View file

@ -2,7 +2,7 @@ import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
class DatabaseHelper { class DatabaseHelper {
static const _databaseVersion = 15; static const _databaseVersion = 17;
static final DatabaseHelper _instance = DatabaseHelper._internal(); static final DatabaseHelper _instance = DatabaseHelper._internal();
static Database? _database; static Database? _database;
@ -105,6 +105,45 @@ class DatabaseHelper {
await _safeAddColumn(db, 'customers', 'is_locked INTEGER DEFAULT 0'); await _safeAddColumn(db, 'customers', 'is_locked INTEGER DEFAULT 0');
await _safeAddColumn(db, 'products', 'is_locked INTEGER DEFAULT 0'); await _safeAddColumn(db, 'products', 'is_locked INTEGER DEFAULT 0');
} }
if (oldVersion < 16) {
await db.execute('''
CREATE TABLE customer_contacts (
id TEXT PRIMARY KEY,
customer_id TEXT NOT NULL,
email TEXT,
tel TEXT,
address TEXT,
version INTEGER NOT NULL,
is_active INTEGER DEFAULT 1,
created_at TEXT NOT NULL,
FOREIGN KEY(customer_id) REFERENCES customers(id) ON DELETE CASCADE
)
''');
await db.execute('CREATE INDEX idx_customer_contacts_cust ON customer_contacts(customer_id)');
//
final existing = await db.query('customers');
final now = DateTime.now().toIso8601String();
for (final row in existing) {
final contactId = "${row['id']}_v1";
await db.insert('customer_contacts', {
'id': contactId,
'customer_id': row['id'],
'email': null,
'tel': row['tel'],
'address': row['address'],
'version': 1,
'is_active': 1,
'created_at': now,
});
}
}
if (oldVersion < 17) {
await _safeAddColumn(db, 'invoices', 'contact_version_id INTEGER');
await _safeAddColumn(db, 'invoices', 'contact_email_snapshot TEXT');
await _safeAddColumn(db, 'invoices', 'contact_tel_snapshot TEXT');
await _safeAddColumn(db, 'invoices', 'contact_address_snapshot TEXT');
}
} }
Future<void> _onCreate(Database db, int version) async { Future<void> _onCreate(Database db, int version) async {
@ -135,6 +174,21 @@ class DatabaseHelper {
) )
'''); ''');
await db.execute('''
CREATE TABLE customer_contacts (
id TEXT PRIMARY KEY,
customer_id TEXT NOT NULL,
email TEXT,
tel TEXT,
address TEXT,
version INTEGER NOT NULL,
is_active INTEGER DEFAULT 1,
created_at TEXT NOT NULL,
FOREIGN KEY(customer_id) REFERENCES customers(id) ON DELETE CASCADE
)
''');
await db.execute('CREATE INDEX idx_customer_contacts_cust ON customer_contacts(customer_id)');
// //
await db.execute(''' await db.execute('''
CREATE TABLE products ( CREATE TABLE products (
@ -173,6 +227,10 @@ class DatabaseHelper {
content_hash TEXT, content_hash TEXT,
is_draft INTEGER DEFAULT 0, is_draft INTEGER DEFAULT 0,
is_locked INTEGER DEFAULT 0, is_locked INTEGER DEFAULT 0,
contact_version_id INTEGER,
contact_email_snapshot TEXT,
contact_tel_snapshot TEXT,
contact_address_snapshot TEXT,
FOREIGN KEY (customer_id) REFERENCES customers (id) FOREIGN KEY (customer_id) REFERENCES customers (id)
) )
'''); ''');

View file

@ -3,6 +3,7 @@ import 'package:sqflite/sqflite.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import '../models/invoice_models.dart'; import '../models/invoice_models.dart';
import '../models/customer_model.dart'; import '../models/customer_model.dart';
import '../models/customer_contact.dart';
import 'database_helper.dart'; import 'database_helper.dart';
import 'activity_log_repository.dart'; import 'activity_log_repository.dart';
@ -17,6 +18,19 @@ class InvoiceRepository {
final Invoice toSave = invoice.isDraft ? invoice : invoice.copyWith(isLocked: true); final Invoice toSave = invoice.isDraft ? invoice : invoice.copyWith(isLocked: true);
await db.transaction((txn) async { await db.transaction((txn) async {
//
CustomerContact? activeContact;
final contactRows = await txn.query('customer_contacts', where: 'customer_id = ? AND is_active = 1', whereArgs: [invoice.customer.id]);
if (contactRows.isNotEmpty) {
activeContact = CustomerContact.fromMap(contactRows.first);
}
final Invoice savingWithContact = toSave.copyWith(
contactVersionId: activeContact?.version,
contactEmailSnapshot: activeContact?.email,
contactTelSnapshot: activeContact?.tel,
contactAddressSnapshot: activeContact?.address,
);
// 調 // 調
final List<Map<String, dynamic>> oldItems = await txn.query( final List<Map<String, dynamic>> oldItems = await txn.query(
'invoice_items', 'invoice_items',
@ -37,7 +51,7 @@ class InvoiceRepository {
// //
await txn.insert( await txn.insert(
'invoices', 'invoices',
toSave.toMap(), savingWithContact.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace, conflictAlgorithm: ConflictAlgorithm.replace,
); );
@ -126,6 +140,10 @@ class InvoiceRepository {
isDraft: (iMap['is_draft'] ?? 0) == 1, isDraft: (iMap['is_draft'] ?? 0) == 1,
subject: iMap['subject'], subject: iMap['subject'],
isLocked: (iMap['is_locked'] ?? 0) == 1, isLocked: (iMap['is_locked'] ?? 0) == 1,
contactVersionId: iMap['contact_version_id'],
contactEmailSnapshot: iMap['contact_email_snapshot'],
contactTelSnapshot: iMap['contact_tel_snapshot'],
contactAddressSnapshot: iMap['contact_address_snapshot'],
)); ));
} }
return invoices; return invoices;

View file

@ -14,238 +14,217 @@ import 'activity_log_repository.dart';
Future<pw.Document> buildInvoiceDocument(Invoice invoice) async { Future<pw.Document> buildInvoiceDocument(Invoice invoice) async {
final pdf = pw.Document(); final pdf = pw.Document();
//
final fontData = await rootBundle.load("assets/fonts/ipaexg.ttf"); final fontData = await rootBundle.load("assets/fonts/ipaexg.ttf");
final ipaex = pw.Font.ttf(fontData); final ipaex = pw.Font.ttf(fontData);
final dateFormatter = DateFormat('yyyy年MM月dd日'); final dateFormatter = DateFormat('yyyy年MM月dd日');
final amountFormatter = NumberFormat("#,###"); final amountFormatter = NumberFormat("#,###");
//
final companyRepo = CompanyRepository(); final companyRepo = CompanyRepository();
final companyInfo = await companyRepo.getCompanyInfo(); final companyInfo = await companyRepo.getCompanyInfo();
//
pw.MemoryImage? sealImage; pw.MemoryImage? sealImage;
if (companyInfo.sealPath != null) { if (companyInfo.sealPath != null) {
final file = File(companyInfo.sealPath!); final file = File(companyInfo.sealPath!);
if (await file.exists()) { if (await file.exists()) {
final bytes = await file.readAsBytes(); sealImage = pw.MemoryImage(await file.readAsBytes());
sealImage = pw.MemoryImage(bytes);
} }
} }
pdf.addPage( pdf.addPage(
pw.MultiPage( pw.MultiPage(
pageFormat: PdfPageFormat.a4, pageTheme: pw.PageTheme(
margin: const pw.EdgeInsets.all(32), pageFormat: PdfPageFormat.a4,
theme: pw.ThemeData.withFont( margin: const pw.EdgeInsets.all(32),
base: ipaex, theme: pw.ThemeData.withFont(
bold: ipaex, base: ipaex,
italic: ipaex, bold: ipaex,
boldItalic: ipaex, italic: ipaex,
).copyWith( boldItalic: ipaex,
defaultTextStyle: pw.TextStyle(fontFallback: [ipaex]), ).copyWith(defaultTextStyle: pw.TextStyle(fontFallback: [ipaex])),
buildBackground: (context) {
if (!invoice.isDraft) return pw.SizedBox();
return pw.Center(
child: pw.Transform.rotate(
angle: -0.5,
child: pw.Opacity(
opacity: 0.18,
child: pw.Text(
'下書き',
style: pw.TextStyle(
fontSize: 120,
fontWeight: pw.FontWeight.bold,
color: PdfColors.grey600,
),
),
),
),
);
},
), ),
build: (context) => [ build: (context) {
// final content = <pw.Widget>[
pw.Header( pw.Header(
level: 0, level: 0,
child: pw.Row( child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(invoice.documentTypeName, style: pw.TextStyle(fontSize: 28, fontWeight: pw.FontWeight.bold)),
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
pw.Text("番号: ${invoice.invoiceNumber}"),
pw.Text("発行日: ${dateFormatter.format(invoice.date)}"),
],
),
],
),
),
pw.SizedBox(height: 20),
pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [ children: [
pw.Text(invoice.documentTypeName, style: pw.TextStyle(fontSize: 28, fontWeight: pw.FontWeight.bold)), pw.Expanded(
pw.Column( child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.end, crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [ children: [
pw.Text("番号: ${invoice.invoiceNumber}"), pw.Container(
pw.Text("発行日: ${dateFormatter.format(invoice.date)}"), decoration: const pw.BoxDecoration(border: pw.Border(bottom: pw.BorderSide(width: 1))),
], child: pw.Text(invoice.customer.invoiceName, style: const pw.TextStyle(fontSize: 18)),
),
pw.SizedBox(height: 6),
if ((invoice.contactAddressSnapshot ?? invoice.customer.address) != null)
pw.Text(invoice.contactAddressSnapshot ?? invoice.customer.address!, style: const pw.TextStyle(fontSize: 12)),
if ((invoice.contactTelSnapshot ?? invoice.customer.tel) != null)
pw.Text("TEL: ${invoice.contactTelSnapshot ?? invoice.customer.tel}", style: const pw.TextStyle(fontSize: 12)),
if (invoice.contactEmailSnapshot != null)
pw.Text("MAIL: ${invoice.contactEmailSnapshot}", style: const pw.TextStyle(fontSize: 12)),
pw.SizedBox(height: 10),
pw.Text(
invoice.documentType == DocumentType.receipt
? "上記の金額を正に領収いたしました。"
: (invoice.documentType == DocumentType.estimation
? "下記の通り、お見積り申し上げます。"
: "下記の通り、ご請求申し上げます。"),
),
],
),
),
pw.Expanded(
child: pw.Stack(
alignment: pw.Alignment.topRight,
children: [
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
pw.Text(companyInfo.name, style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)),
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.registrationNumber != null && companyInfo.registrationNumber!.isNotEmpty)
pw.Text("登録番号: ${companyInfo.registrationNumber!}", style: const pw.TextStyle(fontSize: 10)),
],
),
if (sealImage != null)
pw.Positioned(
right: 10,
top: 0,
child: pw.Opacity(opacity: 0.8, child: pw.Image(sealImage, width: 40, height: 40)),
),
],
),
), ),
], ],
), ),
), pw.SizedBox(height: 30),
pw.SizedBox(height: 20), pw.Container(
padding: const pw.EdgeInsets.all(8),
// decoration: const pw.BoxDecoration(color: PdfColors.grey200),
pw.Row( child: pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.start, mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [ children: [
pw.Expanded( pw.Text(
child: pw.Column( invoice.documentType == DocumentType.receipt
? (companyInfo.taxDisplayMode == 'hidden' ? "領収金額" : "領収金額 (税込)")
: (companyInfo.taxDisplayMode == 'hidden' ? "合計金額" : "合計金額 (税込)"),
style: const pw.TextStyle(fontSize: 16),
),
pw.Text("${amountFormatter.format(invoice.totalAmount)} -", style: pw.TextStyle(fontSize: 20, fontWeight: pw.FontWeight.bold)),
],
),
),
pw.SizedBox(height: 20),
pw.Table.fromTextArray(
headers: const ["品名", "数量", "単価", "金額"],
data: invoice.items
.map((item) => [
item.description,
item.quantity.toString(),
amountFormatter.format(item.unitPrice),
amountFormatter.format(item.subtotal),
])
.toList(),
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, font: ipaex),
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)},
),
pw.SizedBox(height: 20),
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.end,
children: [
pw.Container(
width: 200,
child: pw.Column(
children: [
pw.SizedBox(height: 10),
_buildSummaryRow("小計 (税抜)", amountFormatter.format(invoice.subtotal)),
if (companyInfo.taxDisplayMode == 'normal')
_buildSummaryRow("消費税 (${(invoice.taxRate * 100).toInt()}%)", amountFormatter.format(invoice.tax)),
if (companyInfo.taxDisplayMode == 'text_only') _buildSummaryRow("消費税", "(税別)"),
pw.Divider(),
_buildSummaryRow("合計", "${amountFormatter.format(invoice.totalAmount)}", isBold: true),
],
),
),
],
),
if (invoice.notes != null && invoice.notes!.isNotEmpty) ...[
pw.SizedBox(height: 10),
pw.Text("備考:", style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
pw.Container(
width: double.infinity,
padding: const pw.EdgeInsets.all(8),
decoration: pw.BoxDecoration(border: pw.Border.all(color: PdfColors.grey400)),
child: pw.Text(invoice.notes!),
),
],
pw.SizedBox(height: 20),
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start, crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [ children: [
pw.Container( pw.Text("Verification Hash (SHA256):", style: pw.TextStyle(fontSize: 8, color: PdfColors.grey700)),
decoration: const pw.BoxDecoration( pw.Text(invoice.contentHash, style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold, color: PdfColors.grey700)),
border: pw.Border(bottom: pw.BorderSide(width: 1)),
),
child: pw.Text(invoice.customer.invoiceName,
style: const pw.TextStyle(fontSize: 18)),
),
pw.SizedBox(height: 10),
pw.Text(invoice.documentType == DocumentType.receipt
? "上記の金額を正に領収いたしました。"
: (invoice.documentType == DocumentType.estimation
? "下記の通り、お見積り申し上げます。"
: "下記の通り、ご請求申し上げます。")),
], ],
), ),
), pw.Container(
pw.Expanded( width: 50,
child: pw.Stack( height: 50,
alignment: pw.Alignment.topRight, child: pw.BarcodeWidget(barcode: pw.Barcode.qrCode(), data: invoice.contentHash, drawText: false),
children: [
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
pw.Text(companyInfo.name, style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)),
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.registrationNumber != null && companyInfo.registrationNumber!.isNotEmpty)
pw.Text("登録番号: ${companyInfo.registrationNumber!}", style: const pw.TextStyle(fontSize: 10)),
],
),
if (sealImage != null)
pw.Positioned(
right: 10,
top: 0,
child: pw.Opacity(
opacity: 0.8,
child: pw.Image(sealImage, width: 40, height: 40),
),
),
],
), ),
),
],
),
pw.SizedBox(height: 30),
//
pw.Container(
padding: const pw.EdgeInsets.all(8),
decoration: const pw.BoxDecoration(color: PdfColors.grey200),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(
invoice.documentType == DocumentType.receipt
? (companyInfo.taxDisplayMode == 'hidden' ? "領収金額" : "領収金額 (税込)")
: (companyInfo.taxDisplayMode == 'hidden' ? "合計金額" : "合計金額 (税込)"),
style: const pw.TextStyle(fontSize: 16)),
pw.Text("${amountFormatter.format(invoice.totalAmount)} -",
style: pw.TextStyle(fontSize: 20, fontWeight: pw.FontWeight.bold)),
], ],
), ),
), ];
pw.SizedBox(height: 20),
// return [pw.Column(children: content)];
// },
pw.Table(
border: pw.TableBorder.all(color: PdfColors.grey300),
columnWidths: {
0: const pw.FlexColumnWidth(4),
1: const pw.FixedColumnWidth(50),
2: const pw.FixedColumnWidth(80),
3: const pw.FixedColumnWidth(80),
},
children: [
//
pw.TableRow(
decoration: const pw.BoxDecoration(color: PdfColors.grey300),
children: [
pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text("品名 / 項目", style: pw.TextStyle(fontWeight: pw.FontWeight.bold))),
pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text("数量", style: pw.TextStyle(fontWeight: pw.FontWeight.bold), textAlign: pw.TextAlign.right)),
pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text("単価", style: pw.TextStyle(fontWeight: pw.FontWeight.bold), textAlign: pw.TextAlign.right)),
pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text("金額", style: pw.TextStyle(fontWeight: pw.FontWeight.bold), textAlign: pw.TextAlign.right)),
],
),
//
...invoice.items.map((item) {
return pw.TableRow(
children: [
pw.Padding(
padding: const pw.EdgeInsets.all(4),
child: _parseMarkdown(item.description),
),
pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text(item.quantity.toString(), textAlign: pw.TextAlign.right)),
pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text(amountFormatter.format(item.unitPrice), textAlign: pw.TextAlign.right)),
pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text(amountFormatter.format(item.subtotal), textAlign: pw.TextAlign.right)),
],
);
}),
],
),
//
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.end,
children: [
pw.Container(
width: 200,
child: pw.Column(
children: [
pw.SizedBox(height: 10),
_buildSummaryRow("小計 (税抜)", amountFormatter.format(invoice.subtotal)),
if (companyInfo.taxDisplayMode == 'normal')
_buildSummaryRow("消費税 (${(invoice.taxRate * 100).toInt()}%)", amountFormatter.format(invoice.tax)),
if (companyInfo.taxDisplayMode == 'text_only')
_buildSummaryRow("消費税", "(税別)"),
pw.Divider(),
_buildSummaryRow("合計", "${amountFormatter.format(invoice.totalAmount)}", isBold: true),
],
),
),
],
),
//
//
if (invoice.notes != null && invoice.notes!.isNotEmpty) ...[
pw.SizedBox(height: 10),
pw.Text("備考:", style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
pw.Container(
width: double.infinity,
padding: const pw.EdgeInsets.all(8),
decoration: pw.BoxDecoration(border: pw.Border.all(color: PdfColors.grey400)),
child: pw.Text(invoice.notes!),
),
],
pw.SizedBox(height: 20),
// QRコード
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text("Verification Hash (SHA256):", style: pw.TextStyle(fontSize: 8, color: PdfColors.grey700)),
pw.Text(invoice.contentHash, style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold, color: PdfColors.grey700)),
],
),
pw.Container(
width: 50,
height: 50,
child: pw.BarcodeWidget(
barcode: pw.Barcode.qrCode(),
data: invoice.contentHash,
drawText: false,
),
),
],
),
],
footer: (context) => pw.Container( footer: (context) => pw.Container(
alignment: pw.Alignment.centerRight, alignment: pw.Alignment.centerRight,
margin: const pw.EdgeInsets.only(top: 16), margin: const pw.EdgeInsets.only(top: 16),
child: pw.Text( child: pw.Text("Page ${context.pageNumber} / ${context.pagesCount}", style: const pw.TextStyle(color: PdfColors.grey)),
"Page ${context.pageNumber} / ${context.pagesCount}",
style: const pw.TextStyle(color: PdfColors.grey),
),
), ),
), ),
); );

View file

@ -0,0 +1,117 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:printing/printing.dart';
import '../models/invoice_models.dart';
import '../services/pdf_generator.dart';
class InvoicePdfPreviewPage extends StatelessWidget {
final Invoice invoice;
final bool allowFormalIssue;
final bool isUnlocked;
final bool isLocked;
final Future<bool> Function()? onFormalIssue;
final bool showShare;
final bool showEmail;
final bool showPrint;
const InvoicePdfPreviewPage({
Key? key,
required this.invoice,
this.allowFormalIssue = true,
this.isUnlocked = false,
this.isLocked = false,
this.onFormalIssue,
this.showShare = true,
this.showEmail = true,
this.showPrint = true,
}) : super(key: key);
Future<Uint8List> _buildPdfBytes() async {
final doc = await buildInvoiceDocument(invoice);
return Uint8List.fromList(await doc.save());
}
@override
Widget build(BuildContext context) {
final isDraft = invoice.isDraft;
return Scaffold(
appBar: AppBar(title: const Text("PDFプレビュー")),
body: Column(
children: [
Expanded(
child: PdfPreview(
build: (format) async => await _buildPdfBytes(),
allowPrinting: false,
allowSharing: false,
canChangePageFormat: false,
canChangeOrientation: false,
canDebug: false,
actions: const [],
),
),
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 16),
child: Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: (allowFormalIssue && isDraft && isUnlocked && !isLocked && onFormalIssue != null)
? () async {
final ok = await onFormalIssue!();
if (ok && context.mounted) Navigator.pop(context, true);
}
: null,
icon: const Icon(Icons.check_circle_outline),
label: const Text("正式発行"),
style: ElevatedButton.styleFrom(backgroundColor: Colors.orange, foregroundColor: Colors.white),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton.icon(
onPressed: showShare
? () async {
final bytes = await _buildPdfBytes();
await Printing.sharePdf(bytes: bytes, filename: 'invoice.pdf');
}
: null,
icon: const Icon(Icons.share),
label: const Text("共有"),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton.icon(
onPressed: showEmail
? () async {
final bytes = await _buildPdfBytes();
await Printing.sharePdf(bytes: bytes, filename: 'invoice.pdf', subject: '請求書送付');
}
: null,
icon: const Icon(Icons.mail_outline),
label: const SizedBox.shrink(),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton.icon(
onPressed: showPrint
? () async {
await Printing.layoutPdf(onLayout: (format) async => await _buildPdfBytes());
}
: null,
icon: const Icon(Icons.print),
label: const SizedBox.shrink(),
),
),
],
),
),
)
],
),
);
}
}

View file

@ -3,8 +3,8 @@ FLUTTER_ROOT=/home/user/development/flutter
FLUTTER_APPLICATION_PATH=/home/user/dev/h-1.flutter.0 FLUTTER_APPLICATION_PATH=/home/user/dev/h-1.flutter.0
COCOAPODS_PARALLEL_CODE_SIGN=true COCOAPODS_PARALLEL_CODE_SIGN=true
FLUTTER_BUILD_DIR=build FLUTTER_BUILD_DIR=build
FLUTTER_BUILD_NAME=1.5.0 FLUTTER_BUILD_NAME=1.5.06
FLUTTER_BUILD_NUMBER=150 FLUTTER_BUILD_NUMBER=151
DART_OBFUSCATION=false DART_OBFUSCATION=false
TRACK_WIDGET_CREATION=true TRACK_WIDGET_CREATION=true
TREE_SHAKE_ICONS=false TREE_SHAKE_ICONS=false

View file

@ -4,8 +4,8 @@ export "FLUTTER_ROOT=/home/user/development/flutter"
export "FLUTTER_APPLICATION_PATH=/home/user/dev/h-1.flutter.0" export "FLUTTER_APPLICATION_PATH=/home/user/dev/h-1.flutter.0"
export "COCOAPODS_PARALLEL_CODE_SIGN=true" export "COCOAPODS_PARALLEL_CODE_SIGN=true"
export "FLUTTER_BUILD_DIR=build" export "FLUTTER_BUILD_DIR=build"
export "FLUTTER_BUILD_NAME=1.5.0" export "FLUTTER_BUILD_NAME=1.5.06"
export "FLUTTER_BUILD_NUMBER=150" export "FLUTTER_BUILD_NUMBER=151"
export "DART_OBFUSCATION=false" export "DART_OBFUSCATION=false"
export "TRACK_WIDGET_CREATION=true" export "TRACK_WIDGET_CREATION=true"
export "TREE_SHAKE_ICONS=false" export "TREE_SHAKE_ICONS=false"