Compare commits

...

1 commit
ollama ... main

Author SHA1 Message Date
joe
214a7065a2 顧客マスター 2026-02-01 12:12:35 +09:00
18 changed files with 1387 additions and 277 deletions

View file

@ -1,16 +1,19 @@
// lib/main.dart // lib/main.dart
// version: 1.4.3c (Bug Fix: PDF layout error) - Refactored for modularity // version: 1.4.3c (Bug Fix: PDF layout error) - Refactored for modularity and history management
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
// --- --- // --- ---
import 'models/invoice_models.dart'; // Invoice, InvoiceItem import 'models/invoice_models.dart';
import 'screens/invoice_input_screen.dart'; // import 'screens/invoice_input_screen.dart';
import 'screens/invoice_detail_page.dart'; // import 'screens/invoice_detail_page.dart';
import 'screens/invoice_history_screen.dart';
import 'screens/company_editor_screen.dart'; //
void main() { void main() {
runApp(const MyApp()); runApp(const MyApp());
} }
//
class MyApp extends StatelessWidget { class MyApp extends StatelessWidget {
const MyApp({super.key}); const MyApp({super.key});
@ -22,30 +25,87 @@ class MyApp extends StatelessWidget {
primarySwatch: Colors.blueGrey, primarySwatch: Colors.blueGrey,
visualDensity: VisualDensity.adaptivePlatformDensity, visualDensity: VisualDensity.adaptivePlatformDensity,
useMaterial3: true, useMaterial3: true,
fontFamily: 'IPAexGothic',
), ),
home: const InvoiceFlowScreen(), home: const MainNavigationShell(),
); );
} }
} }
class InvoiceFlowScreen extends StatefulWidget { ///
const InvoiceFlowScreen({super.key}); class MainNavigationShell extends StatefulWidget {
const MainNavigationShell({super.key});
@override @override
State<InvoiceFlowScreen> createState() => _InvoiceFlowScreenState(); State<MainNavigationShell> createState() => _MainNavigationShellState();
} }
class _InvoiceFlowScreenState extends State<InvoiceFlowScreen> { class _MainNavigationShellState extends State<MainNavigationShell> {
// int _selectedIndex = 0;
Invoice? _lastGeneratedInvoice;
//
final List<Widget> _screens = [];
@override
void initState() {
super.initState();
_screens.addAll([
InvoiceFlowScreen(onMoveToHistory: () => _onItemTapped(1)),
const InvoiceHistoryScreen(),
]);
}
void _onItemTapped(int index) {
setState(() {
_selectedIndex = index;
});
}
//
void _openCompanyEditor(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const CompanyEditorScreen(),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _selectedIndex,
children: _screens,
),
bottomNavigationBar: BottomNavigationBar(
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: Icon(Icons.add_box),
label: '新規作成',
),
BottomNavigationBarItem(
icon: Icon(Icons.history),
label: '発行履歴',
),
],
currentIndex: _selectedIndex,
selectedItemColor: Colors.indigo,
onTap: _onItemTapped,
),
);
}
}
///
class InvoiceFlowScreen extends StatelessWidget {
final VoidCallback onMoveToHistory;
const InvoiceFlowScreen({super.key, required this.onMoveToHistory});
// PDF // PDF
void _handleInvoiceGenerated(Invoice generatedInvoice, String filePath) { void _handleInvoiceGenerated(BuildContext context, Invoice generatedInvoice, String filePath) {
setState(() { // PDF生成DB保存後に詳細ページへ遷移
_lastGeneratedInvoice = generatedInvoice;
});
//
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@ -54,16 +114,31 @@ class _InvoiceFlowScreenState extends State<InvoiceFlowScreen> {
); );
} }
//
void _openCompanyEditor(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const CompanyEditorScreen(),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text("販売アシスト1号 V1.4.3c"), //
title: GestureDetector(
onLongPress: () => _openCompanyEditor(context),
child: const Text("販売アシスト1号 V1.4.3c"),
),
backgroundColor: Colors.blueGrey, backgroundColor: Colors.blueGrey,
foregroundColor: Colors.white,
), ),
// //
body: InvoiceInputForm( body: InvoiceInputForm(
onInvoiceGenerated: _handleInvoiceGenerated, onInvoiceGenerated: (invoice, path) => _handleInvoiceGenerated(context, invoice, path),
), ),
); );
} }

View file

@ -0,0 +1,106 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
///
///
class Company {
final String id; // ID ()
final String formalName; // (: )
final String? representative; //
final String? zipCode; // 便
final String? address; //
final String? tel; //
final String? fax; // FAX番号
final String? email; //
final String? website; //
final String? registrationNumber; // ()
final String? notes; //
const Company({
required this.id,
required this.formalName,
this.representative,
this.zipCode,
this.address,
this.tel,
this.fax,
this.email,
this.website,
this.registrationNumber,
this.notes,
});
///
Company copyWith({
String? id,
String? formalName,
String? representative,
String? zipCode,
String? address,
String? tel,
String? fax,
String? email,
String? website,
String? registrationNumber,
String? notes,
}) {
return Company(
id: id ?? this.id,
formalName: formalName ?? this.formalName,
representative: representative ?? this.representative,
zipCode: zipCode ?? this.zipCode,
address: address ?? this.address,
tel: tel ?? this.tel,
fax: fax ?? this.fax,
email: email ?? this.email,
website: website ?? this.website,
registrationNumber: registrationNumber ?? this.registrationNumber,
notes: notes ?? this.notes,
);
}
/// JSON変換 ()
Map<String, dynamic> toJson() {
return {
'id': id,
'formal_name': formalName,
'representative': representative,
'zip_code': zipCode,
'address': address,
'tel': tel,
'fax': fax,
'email': email,
'website': website,
'registration_number': registrationNumber,
'notes': notes,
};
}
/// JSONからモデルを生成
factory Company.fromJson(Map<String, dynamic> json) {
return Company(
id: json['id'] as String,
formalName: json['formal_name'] as String,
representative: json['representative'] as String?,
zipCode: json['zip_code'] as String?,
address: json['address'] as String?,
tel: json['tel'] as String?,
fax: json['fax'] as String?,
email: json['email'] as String?,
website: json['website'] as String?,
registrationNumber: json['registration_number'] as String?,
notes: json['notes'] as String?,
);
}
// ()
static const Company defaultCompany = Company(
id: 'my_company',
formalName: '自社名が入ります',
zipCode: '〒000-0000',
address: '住所がここに入ります',
tel: 'TEL: 00-0000-0000',
registrationNumber: '適格請求書発行事業者登録番号 T1234567890123', //
notes: 'いつもお世話になっております。',
);
}

View file

@ -1,31 +1,47 @@
// lib/models/invoice_models.dart
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'customer_model.dart'; import 'customer_model.dart';
///
enum DocumentType {
estimate('見積書'),
delivery('納品書'),
invoice('請求書'),
receipt('領収書');
final String label;
const DocumentType(this.label);
}
/// ///
class InvoiceItem { class InvoiceItem {
String description; String description;
int quantity; int quantity;
int unitPrice; int unitPrice;
bool isDiscount; //
InvoiceItem({ InvoiceItem({
required this.description, required this.description,
required this.quantity, required this.quantity,
required this.unitPrice, required this.unitPrice,
this.isDiscount = false, // false ()
}); });
// ( * ) // ( * )
int get subtotal => quantity * unitPrice; int get subtotal => quantity * unitPrice * (isDiscount ? -1 : 1);
// //
InvoiceItem copyWith({ InvoiceItem copyWith({
String? description, String? description,
int? quantity, int? quantity,
int? unitPrice, int? unitPrice,
bool? isDiscount,
}) { }) {
return InvoiceItem( return InvoiceItem(
description: description ?? this.description, description: description ?? this.description,
quantity: quantity ?? this.quantity, quantity: quantity ?? this.quantity,
unitPrice: unitPrice ?? this.unitPrice, unitPrice: unitPrice ?? this.unitPrice,
isDiscount: isDiscount ?? this.isDiscount,
); );
} }
@ -35,6 +51,7 @@ class InvoiceItem {
'description': description, 'description': description,
'quantity': quantity, 'quantity': quantity,
'unit_price': unitPrice, 'unit_price': unitPrice,
'is_discount': isDiscount,
}; };
} }
@ -44,11 +61,12 @@ class InvoiceItem {
description: json['description'] as String, description: json['description'] as String,
quantity: json['quantity'] as int, quantity: json['quantity'] as int,
unitPrice: json['unit_price'] as int, unitPrice: json['unit_price'] as int,
isDiscount: json['is_discount'] ?? false,
); );
} }
} }
/// /// ()
class Invoice { class Invoice {
Customer customer; // Customer customer; //
DateTime date; DateTime date;
@ -56,6 +74,8 @@ class Invoice {
String? filePath; // PDFのパス String? filePath; // PDFのパス
String invoiceNumber; // String invoiceNumber; //
String? notes; // String? notes; //
bool isShared; //
DocumentType type; //
Invoice({ Invoice({
required this.customer, required this.customer,
@ -64,6 +84,8 @@ class Invoice {
this.filePath, this.filePath,
String? invoiceNumber, String? invoiceNumber,
this.notes, this.notes,
this.isShared = false,
this.type = DocumentType.invoice,
}) : invoiceNumber = invoiceNumber ?? DateFormat('yyyyMMdd-HHmm').format(date); }) : invoiceNumber = invoiceNumber ?? DateFormat('yyyyMMdd-HHmm').format(date);
// //
@ -92,6 +114,8 @@ class Invoice {
String? filePath, String? filePath,
String? invoiceNumber, String? invoiceNumber,
String? notes, String? notes,
bool? isShared,
DocumentType? type,
}) { }) {
return Invoice( return Invoice(
customer: customer ?? this.customer, customer: customer ?? this.customer,
@ -100,19 +124,23 @@ class Invoice {
filePath: filePath ?? this.filePath, filePath: filePath ?? this.filePath,
invoiceNumber: invoiceNumber ?? this.invoiceNumber, invoiceNumber: invoiceNumber ?? this.invoiceNumber,
notes: notes ?? this.notes, notes: notes ?? this.notes,
isShared: isShared ?? this.isShared,
type: type ?? this.type,
); );
} }
// CSV形式への変換 // CSV形式への変換
String toCsv() { String toCsv() {
StringBuffer sb = StringBuffer(); StringBuffer sb = StringBuffer();
sb.writeln("Type,${type.label}");
sb.writeln("Customer,${customer.formalName}"); sb.writeln("Customer,${customer.formalName}");
sb.writeln("Invoice Number,$invoiceNumber"); sb.writeln("Number,$invoiceNumber");
sb.writeln("Date,${DateFormat('yyyy/MM/dd').format(date)}"); sb.writeln("Date,${DateFormat('yyyy/MM/dd').format(date)}");
sb.writeln("Shared,${isShared ? 'Yes' : 'No'}");
sb.writeln(""); sb.writeln("");
sb.writeln("Description,Quantity,UnitPrice,Subtotal"); sb.writeln("Description,Quantity,UnitPrice,Subtotal,IsDiscount"); // isDiscountを追加
for (var item in items) { for (var item in items) {
sb.writeln("${item.description},${item.quantity},${item.unitPrice},${item.subtotal}"); sb.writeln("${item.description},${item.quantity},${item.unitPrice},${item.subtotal},${item.isDiscount ? 'Yes' : 'No'}");
} }
return sb.toString(); return sb.toString();
} }
@ -126,6 +154,8 @@ class Invoice {
'file_path': filePath, 'file_path': filePath,
'invoice_number': invoiceNumber, 'invoice_number': invoiceNumber,
'notes': notes, 'notes': notes,
'is_shared': isShared,
'type': type.name, // Enumの名前で保存
}; };
} }
@ -139,7 +169,12 @@ class Invoice {
.toList(), .toList(),
filePath: json['file_path'] as String?, filePath: json['file_path'] as String?,
invoiceNumber: json['invoice_number'] as String, invoiceNumber: json['invoice_number'] as String,
notes: json['notes'] as String?, notes: (json['notes'] == 'null') ? null : json['notes'] as String?, // 'null'
isShared: json['is_shared'] ?? false,
type: DocumentType.values.firstWhere(
(e) => e.name == (json['type'] ?? 'invoice'),
orElse: () => DocumentType.invoice,
),
); );
} }
} }

View file

@ -0,0 +1,206 @@
// lib/screens/company_editor_screen.dart
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import '../models/company_model.dart';
import '../services/master_repository.dart';
///
class CompanyEditorScreen extends StatefulWidget {
const CompanyEditorScreen({super.key});
@override
State<CompanyEditorScreen> createState() => _CompanyEditorScreenState();
}
class _CompanyEditorScreenState extends State<CompanyEditorScreen> {
final _repository = MasterRepository();
final _formKey = GlobalKey<FormState>(); //
late Company _company;
late TextEditingController _formalNameController;
late TextEditingController _representativeController;
late TextEditingController _zipCodeController;
late TextEditingController _addressController;
late TextEditingController _telController;
late TextEditingController _faxController;
late TextEditingController _emailController;
late TextEditingController _websiteController;
late TextEditingController _registrationNumberController;
late TextEditingController _notesController;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadCompanyInfo();
}
Future<void> _loadCompanyInfo() async {
setState(() => _isLoading = true);
_company = await _repository.loadCompany();
_formalNameController = TextEditingController(text: _company.formalName);
_representativeController = TextEditingController(text: _company.representative);
_zipCodeController = TextEditingController(text: _company.zipCode);
_addressController = TextEditingController(text: _company.address);
_telController = TextEditingController(text: _company.tel);
_faxController = TextEditingController(text: _company.fax);
_emailController = TextEditingController(text: _company.email);
_websiteController = TextEditingController(text: _company.website);
_registrationNumberController = TextEditingController(text: _company.registrationNumber);
_notesController = TextEditingController(text: _company.notes);
setState(() => _isLoading = false);
}
Future<void> _saveCompanyInfo() async {
if (!_formKey.currentState!.validate()) {
return;
}
final updatedCompany = _company.copyWith(
formalName: _formalNameController.text.trim(),
representative: _representativeController.text.trim(),
zipCode: _zipCodeController.text.trim(),
address: _addressController.text.trim(),
tel: _telController.text.trim(),
fax: _faxController.text.trim(),
email: _emailController.text.trim(),
website: _websiteController.text.trim(),
registrationNumber: _registrationNumberController.text.trim(),
notes: _notesController.text.trim(),
);
await _repository.saveCompany(updatedCompany);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('自社情報を保存しました。')),
);
Navigator.pop(context); //
}
}
@override
void dispose() {
_formalNameController.dispose();
_representativeController.dispose();
_zipCodeController.dispose();
_addressController.dispose();
_telController.dispose();
_faxController.dispose();
_emailController.dispose();
_websiteController.dispose();
_registrationNumberController.dispose();
_notesController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("自社情報編集"),
backgroundColor: Colors.blueGrey,
foregroundColor: Colors.white,
actions: [
IconButton(
icon: const Icon(Icons.save),
onPressed: _saveCompanyInfo,
tooltip: "保存",
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: Form(
key: _formKey,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: _formalNameController,
decoration: const InputDecoration(labelText: "正式名称 (必須)", border: OutlineInputBorder()),
validator: (value) {
if (value == null || value.isEmpty) {
return '正式名称は必須です';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _representativeController,
decoration: const InputDecoration(labelText: "代表者名", border: OutlineInputBorder()),
),
const SizedBox(height: 16),
TextFormField(
controller: _zipCodeController,
decoration: const InputDecoration(labelText: "郵便番号", border: OutlineInputBorder()),
keyboardType: TextInputType.text,
),
const SizedBox(height: 16),
TextFormField(
controller: _addressController,
decoration: const InputDecoration(labelText: "住所", border: OutlineInputBorder()),
maxLines: 2,
),
const SizedBox(height: 16),
TextFormField(
controller: _telController,
decoration: const InputDecoration(labelText: "電話番号", border: OutlineInputBorder()),
keyboardType: TextInputType.phone,
),
const SizedBox(height: 16),
TextFormField(
controller: _faxController,
decoration: const InputDecoration(labelText: "FAX番号", border: OutlineInputBorder()),
keyboardType: TextInputType.phone,
),
const SizedBox(height: 16),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(labelText: "メールアドレス", border: OutlineInputBorder()),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 16),
TextFormField(
controller: _websiteController,
decoration: const InputDecoration(labelText: "ウェブサイト", border: OutlineInputBorder()),
keyboardType: TextInputType.url,
),
const SizedBox(height: 16),
TextFormField(
controller: _registrationNumberController,
decoration: const InputDecoration(labelText: "登録番号 (インボイス制度対応)", border: OutlineInputBorder()),
),
const SizedBox(height: 16),
TextFormField(
controller: _notesController,
decoration: const InputDecoration(labelText: "備考", border: OutlineInputBorder()),
maxLines: 3,
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _saveCompanyInfo,
icon: const Icon(Icons.save),
label: const Text("自社情報を保存"),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
),
),
),
);
}
}

View file

@ -4,8 +4,9 @@ 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 '../models/invoice_models.dart'; import '../models/invoice_models.dart';
import '../models/customer_model.dart';
import '../services/pdf_generator.dart'; import '../services/pdf_generator.dart';
import '../services/master_repository.dart';
import 'customer_picker_modal.dart';
import 'product_picker_modal.dart'; import 'product_picker_modal.dart';
class InvoiceDetailPage extends StatefulWidget { class InvoiceDetailPage extends StatefulWidget {
@ -24,6 +25,9 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
late bool _isEditing; late bool _isEditing;
late Invoice _currentInvoice; late Invoice _currentInvoice;
String? _currentFilePath; String? _currentFilePath;
final _repository = InvoiceRepository();
final ScrollController _scrollController = ScrollController();
bool _userScrolled = false; //
@override @override
void initState() { void initState() {
@ -40,6 +44,7 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
void dispose() { void dispose() {
_formalNameController.dispose(); _formalNameController.dispose();
_notesController.dispose(); _notesController.dispose();
_scrollController.dispose();
super.dispose(); super.dispose();
} }
@ -47,6 +52,16 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
setState(() { setState(() {
_items.add(InvoiceItem(description: "新項目", quantity: 1, unitPrice: 0)); _items.add(InvoiceItem(description: "新項目", quantity: 1, unitPrice: 0));
}); });
//
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_userScrolled && _scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
} }
void _removeItem(int index) { void _removeItem(int index) {
@ -55,25 +70,6 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
}); });
} }
void _pickFromMaster() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => FractionallySizedBox(
heightFactor: 0.9,
child: ProductPickerModal(
onItemSelected: (item) {
setState(() {
_items.add(item);
});
Navigator.pop(context);
},
),
),
);
}
Future<void> _saveChanges() async { Future<void> _saveChanges() async {
final String formalName = _formalNameController.text.trim(); final String formalName = _formalNameController.text.trim();
if (formalName.isEmpty) { if (formalName.isEmpty) {
@ -91,36 +87,64 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
final updatedInvoice = _currentInvoice.copyWith( final updatedInvoice = _currentInvoice.copyWith(
customer: updatedCustomer, customer: updatedCustomer,
items: _items, items: _items,
notes: _notesController.text, notes: _notesController.text.trim(),
isShared: false, //
); );
setState(() => _isEditing = false); setState(() => _isEditing = false);
// PDFを再生成
final newPath = await generateInvoicePdf(updatedInvoice); final newPath = await generateInvoicePdf(updatedInvoice);
if (newPath != null) { if (newPath != null) {
final finalInvoice = updatedInvoice.copyWith(filePath: newPath);
// DBを更新PDFの物理削除も行われます
await _repository.saveInvoice(finalInvoice);
setState(() { setState(() {
_currentInvoice = updatedInvoice.copyWith(filePath: newPath); _currentInvoice = finalInvoice;
_currentFilePath = newPath; _currentFilePath = newPath;
}); });
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('A4請求書PDFを更新しました')), if (mounted) {
); ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('変更を保存し、PDFを更新しました。')),
);
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('PDFの更新に失敗しました')),
);
}
_cancelChanges(); //
} }
} }
void _cancelChanges() {
setState(() {
_isEditing = false;
_formalNameController.text = _currentInvoice.customer.formalName;
_notesController.text = _currentInvoice.notes ?? "";
// itemsリストは変更されていないのでリセット不要
});
}
void _exportCsv() { void _exportCsv() {
final csvData = _currentInvoice.toCsv(); final csvData = _currentInvoice.toCsv();
Share.share(csvData, subject: '請求書データ_CSV'); Share.share(csvData, subject: '${_currentInvoice.type.label}データ_CSV');
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final amountFormatter = NumberFormat("#,###"); final dateFormatter = DateFormat('yyyy年MM月dd日');
final amountFormatter = NumberFormat("¥#,###");
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text("販売アシスト1号 請求書詳細"), title: Text("販売アシスト1号 ${_currentInvoice.type.label}詳細"),
backgroundColor: Colors.blueGrey, backgroundColor: Colors.blueGrey,
foregroundColor: Colors.white,
actions: [ actions: [
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出力"),
@ -131,78 +155,110 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
] ]
], ],
), ),
body: SingleChildScrollView( body: NotificationListener<ScrollStartNotification>(
padding: const EdgeInsets.all(16.0), onNotification: (notification) {
child: Column( //
crossAxisAlignment: CrossAxisAlignment.start, _userScrolled = true;
children: [ return false;
_buildHeaderSection(), },
const Divider(height: 32), child: SingleChildScrollView(
const Text("明細一覧", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), controller: _scrollController, // ScrollController
const SizedBox(height: 8), padding: const EdgeInsets.all(16.0),
_buildItemTable(amountFormatter), child: Column(
if (_isEditing) crossAxisAlignment: CrossAxisAlignment.start,
Padding( children: [
padding: const EdgeInsets.only(top: 8.0), _buildHeaderSection(),
child: Wrap( const Divider(height: 32),
spacing: 12, const Text("明細一覧", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
runSpacing: 8, const SizedBox(height: 8),
children: [ _buildItemTable(amountFormatter),
ElevatedButton.icon( if (_isEditing)
onPressed: _addItem, Padding(
icon: const Icon(Icons.add), padding: const EdgeInsets.only(top: 8.0),
label: const Text("空の行を追加"), child: Wrap(
), spacing: 12,
ElevatedButton.icon( runSpacing: 8,
onPressed: _pickFromMaster, children: [
icon: const Icon(Icons.list_alt), ElevatedButton.icon(
label: const Text("マスターから選択"), onPressed: _addItem,
style: ElevatedButton.styleFrom( icon: const Icon(Icons.add),
backgroundColor: Colors.blueGrey.shade700, label: const Text("空の行を追加"),
foregroundColor: Colors.white,
), ),
), ElevatedButton.icon(
], onPressed: _pickFromMaster,
icon: const Icon(Icons.list_alt),
label: const Text("マスターから選択"),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueGrey.shade700,
foregroundColor: Colors.white,
),
),
],
),
), ),
), const SizedBox(height: 24),
const SizedBox(height: 24), _buildSummarySection(amountFormatter),
_buildSummarySection(amountFormatter), const SizedBox(height: 24),
const SizedBox(height: 24), _buildFooterActions(),
_buildFooterActions(), ],
], ),
), ),
), ),
); );
} }
Widget _buildHeaderSection() { Widget _buildHeaderSection() {
final dateFormatter = DateFormat('yyyy年MM月dd日');
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (_isEditing) ...[ if (_isEditing) ...[
TextField( TextFormField(
controller: _formalNameController, controller: _formalNameController,
decoration: const InputDecoration(labelText: "取引先 正式名称", border: OutlineInputBorder()), decoration: const InputDecoration(labelText: "取引先 正式名称", border: OutlineInputBorder()),
onChanged: (value) => setState(() {}), //
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField( TextFormField(
controller: _notesController, controller: _notesController,
maxLines: 2,
decoration: const InputDecoration(labelText: "備考", border: OutlineInputBorder()), decoration: const InputDecoration(labelText: "備考", border: OutlineInputBorder()),
maxLines: 2,
onChanged: (value) => setState(() {}), //
), ),
] else ...[ ] else ...[
Text("${_currentInvoice.customer.formalName} ${_currentInvoice.customer.title}", Row(
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text("${_currentInvoice.customer.formalName} ${_currentInvoice.customer.title}",
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis), //
),
if (_currentInvoice.isShared)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.green.shade50,
border: Border.all(color: Colors.green),
borderRadius: BorderRadius.circular(4),
),
child: const Row(
children: [
Icon(Icons.check, color: Colors.green, size: 14),
SizedBox(width: 4),
Text("共有済み", style: TextStyle(color: Colors.green, fontSize: 10, fontWeight: FontWeight.bold)),
],
),
),
],
),
if (_currentInvoice.customer.department != null && _currentInvoice.customer.department!.isNotEmpty) if (_currentInvoice.customer.department != null && _currentInvoice.customer.department!.isNotEmpty)
Text(_currentInvoice.customer.department!, style: const TextStyle(fontSize: 16)), Text(_currentInvoice.customer.department!, style: const TextStyle(fontSize: 16)),
const SizedBox(height: 4), const SizedBox(height: 4),
Text("請求番号: ${_currentInvoice.invoiceNumber}"), Text("発行日: ${DateFormat('yyyy年MM月dd日').format(_currentInvoice.date)}"),
Text("発行日: ${dateFormatter.format(_currentInvoice.date)}"), // InvoiceDetailPageでは unitPrice totalAmount PDF生成時に計算していたため
if (_currentInvoice.notes?.isNotEmpty ?? false) ...[ // `_isEditing` TextField `widget.invoice.unitPrice`
const SizedBox(height: 8), // `_currentInvoice` `unitPrice` `_amountController` 使
Text("備考: ${_currentInvoice.notes}", style: const TextStyle(color: Colors.black87)), // `_currentInvoice.unitPrice` ReadOnly `_amountController` 使
]
], ],
], ],
); );
@ -212,52 +268,58 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
return Table( return Table(
border: TableBorder.all(color: Colors.grey.shade300), border: TableBorder.all(color: Colors.grey.shade300),
columnWidths: const { columnWidths: const {
0: FlexColumnWidth(4), 0: FlexColumnWidth(4), //
1: FixedColumnWidth(50), 1: FixedColumnWidth(50), //
2: FixedColumnWidth(80), 2: FixedColumnWidth(80), //
3: FlexColumnWidth(2), 3: FlexColumnWidth(2), // ()
4: FixedColumnWidth(40), 4: FixedColumnWidth(40), //
}, },
defaultVerticalAlignment: TableCellVerticalAlignment.middle, verticalAlignment: TableCellVerticalAlignment.middle,
children: [ children: [
TableRow( TableRow(
decoration: BoxDecoration(color: Colors.grey.shade100), decoration: BoxDecoration(color: Colors.grey.shade100),
children: const [ children: const [
_TableCell("品名"), _TableCell("数量"), _TableCell("単価"), _TableCell("金額"), _TableCell(""), _TableCell("品名"),
_TableCell("数量"),
_TableCell("単価"),
_TableCell("金額"),
_TableCell(""), //
], ],
), ),
//
..._items.asMap().entries.map((entry) { ..._items.asMap().entries.map((entry) {
int idx = entry.key; int idx = entry.key;
InvoiceItem item = entry.value; InvoiceItem item = entry.value;
if (_isEditing) { return TableRow(children: [
return TableRow(children: [ if (_isEditing)
_EditableCell( _EditableCell(
initialValue: item.description, initialValue: item.description,
onChanged: (val) => item.description = val, onChanged: (val) => setState(() => item.description = val),
), )
else
_TableCell(item.description),
if (_isEditing)
_EditableCell( _EditableCell(
initialValue: item.quantity.toString(), initialValue: item.quantity.toString(),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
onChanged: (val) => setState(() => item.quantity = int.tryParse(val) ?? 0), onChanged: (val) => setState(() => item.quantity = int.tryParse(val) ?? 0),
), )
else
_TableCell(item.quantity.toString()),
if (_isEditing)
_EditableCell( _EditableCell(
initialValue: item.unitPrice.toString(), initialValue: item.unitPrice.toString(),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
onChanged: (val) => setState(() => item.unitPrice = int.tryParse(val) ?? 0), onChanged: (val) => setState(() => item.unitPrice = int.tryParse(val) ?? 0),
), )
_TableCell(formatter.format(item.subtotal)), else
IconButton(icon: const Icon(Icons.delete, size: 20, color: Colors.red), onPressed: () => _removeItem(idx)),
]);
} else {
return TableRow(children: [
_TableCell(item.description),
_TableCell(item.quantity.toString()),
_TableCell(formatter.format(item.unitPrice)), _TableCell(formatter.format(item.unitPrice)),
_TableCell(formatter.format(item.subtotal)), _TableCell(formatter.format(item.subtotal)), //
const SizedBox(), if (_isEditing)
]); IconButton(icon: const Icon(Icons.delete_outline, size: 20, color: Colors.redAccent), onPressed: () => _removeItem(idx)),
} if (!_isEditing) const SizedBox.shrink(), // SizedBox
}), ]);
}).toList(),
], ],
); );
} }
@ -265,22 +327,27 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
Widget _buildSummarySection(NumberFormat formatter) { Widget _buildSummarySection(NumberFormat formatter) {
return Align( return Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: Container( child: SizedBox(
width: 200, width: 200,
child: Column( child: Column(
children: [ children: [
_SummaryRow("小計 (税抜)", formatter.format(_isEditing ? _calculateCurrentSubtotal() : _currentInvoice.subtotal)), _SummaryRow("小計 (税抜)", formatter.format(_isEditing ? _calculateCurrentSubtotal() : _currentInvoice.subtotal)),
_SummaryRow("消費税 (10%)", formatter.format(_isEditing ? (_calculateCurrentSubtotal() * 0.1).floor() : _currentInvoice.tax)), _SummaryRow("消費税 (10%)", formatter.format(_isEditing ? (_calculateCurrentSubtotal() * 0.1).floor() : _currentInvoice.tax)),
const Divider(), const Divider(),
_SummaryRow("合計 (税込)", "${formatter.format(_isEditing ? (_calculateCurrentSubtotal() * 1.1).floor() : _currentInvoice.totalAmount)}", isBold: true), _SummaryRow("合計 (税込)", formatter.format(_isEditing ? (_calculateCurrentSubtotal() * 1.1).floor() : _currentInvoice.totalAmount), isBold: true),
], ],
), ),
), ),
); );
} }
//
int _calculateCurrentSubtotal() { int _calculateCurrentSubtotal() {
return _items.fold(0, (sum, item) => sum + (item.quantity * item.unitPrice)); return _items.fold(0, (sum, item) {
//
int price = item.isDiscount ? -item.unitPrice : item.unitPrice;
return sum + (item.quantity * price);
});
} }
Widget _buildFooterActions() { Widget _buildFooterActions() {
@ -300,7 +367,7 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: _sharePdf, onPressed: _sharePdf,
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),
), ),
), ),
@ -308,8 +375,31 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
); );
} }
Future<void> _openPdf() async => await OpenFilex.open(_currentFilePath!); Future<void> _openPdf() async {
Future<void> _sharePdf() async => await Share.shareXFiles([XFile(_currentFilePath!)], text: '請求書送付'); if (_currentFilePath != null) {
await OpenFilex.open(_currentFilePath!);
}
}
Future<void> _sharePdf() async {
if (_currentFilePath != null) {
await Share.shareXFiles([XFile(_currentFilePath!)], text: '${_currentInvoice.type.label}送付');
// DBに保存
if (!_currentInvoice.isShared) {
final updatedInvoice = _currentInvoice.copyWith(isShared: true);
await _repository.saveInvoice(updatedInvoice);
setState(() {
_currentInvoice = updatedInvoice;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${_currentInvoice.type.label}を共有済みとしてマークしました。')),
);
}
}
}
}
} }
class _TableCell extends StatelessWidget { class _TableCell extends StatelessWidget {
@ -327,7 +417,6 @@ class _EditableCell extends StatelessWidget {
final TextInputType keyboardType; final TextInputType keyboardType;
final Function(String) onChanged; final Function(String) onChanged;
const _EditableCell({required this.initialValue, this.keyboardType = TextInputType.text, required this.onChanged}); const _EditableCell({required this.initialValue, this.keyboardType = TextInputType.text, required this.onChanged});
@override @override
Widget build(BuildContext context) => Padding( Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0), padding: const EdgeInsets.symmetric(horizontal: 4.0),
@ -337,6 +426,8 @@ class _EditableCell extends StatelessWidget {
style: const TextStyle(fontSize: 12), style: const TextStyle(fontSize: 12),
decoration: const InputDecoration(isDense: true, contentPadding: EdgeInsets.all(8)), decoration: const InputDecoration(isDense: true, contentPadding: EdgeInsets.all(8)),
onChanged: onChanged, onChanged: onChanged,
//
scrollPadding: const EdgeInsets.only(bottom: 100), //
), ),
); );
} }
@ -357,3 +448,37 @@ class _SummaryRow extends StatelessWidget {
), ),
); );
} }
```
###
1. ****:
* 調 ****
* ****
*
2. ****:
* ****
*
3. **PDF生成への反映**:
* `pdf_generator.dart` PDF生成ロジックで調
4. **UIの微調整**:
* ¥
* `TextFormField` 使Flutterの標準的な挙動では少し難しいのですが
*
### 使
* ****:
1.
* 調 ****
3.
* ****:
1.
* OK

View file

@ -0,0 +1,186 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../models/invoice_models.dart';
import '../services/invoice_repository.dart';
import 'invoice_detail_page.dart';
///
class InvoiceHistoryScreen extends StatefulWidget {
const InvoiceHistoryScreen({Key? key}) : super(key: key);
@override
State<InvoiceHistoryScreen> createState() => _InvoiceHistoryScreenState();
}
class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
final InvoiceRepository _repository = InvoiceRepository();
List<Invoice> _invoices = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadInvoices();
}
/// DBから履歴を読み込む
Future<void> _loadInvoices() async {
setState(() => _isLoading = true);
final data = await _repository.getAllInvoices();
setState(() {
_invoices = data;
_isLoading = false;
});
}
/// DBに紐付かないPDFファイルを一括削除
Future<void> _cleanupFiles() async {
final count = await _repository.cleanupOrphanedPdfs();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('$count 個の不要なPDFファイルを削除しました')),
);
}
}
///
Future<void> _deleteInvoice(Invoice invoice) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text("削除の確認"),
content: Text("${invoice.type.label}番号: ${invoice.invoiceNumber}\nこのデータを削除しますか?\n(実体PDFファイルも削除されます)"),
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 (confirmed == true) {
await _repository.deleteInvoice(invoice);
_loadInvoices();
}
}
@override
Widget build(BuildContext context) {
final amountFormatter = NumberFormat("#,###");
final dateFormatter = DateFormat('yyyy/MM/dd HH:mm');
return Scaffold(
appBar: AppBar(
title: const Text("発行履歴管理"),
backgroundColor: Colors.blueGrey,
foregroundColor: Colors.white,
actions: [
IconButton(
icon: const Icon(Icons.cleaning_services),
tooltip: "ゴミファイルを掃除",
onPressed: _cleanupFiles,
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadInvoices,
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _invoices.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.history, size: 64, color: Colors.grey.shade300),
const SizedBox(height: 16),
const Text("発行済みの帳票はありません", style: TextStyle(color: Colors.grey)),
],
),
)
: ListView.builder(
itemCount: _invoices.length,
itemBuilder: (context, index) {
final invoice = _invoices[index];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: ListTile(
leading: Stack(
alignment: Alignment.bottomRight,
children: [
const CircleAvatar(
backgroundColor: Colors.indigo,
child: Icon(Icons.description, color: Colors.white),
),
if (invoice.isShared)
Container(
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: const Icon(
Icons.check_circle,
color: Colors.green,
size: 18,
),
),
],
),
title: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.blueGrey.shade100,
borderRadius: BorderRadius.circular(4),
),
child: Text(
invoice.type.label,
style: const TextStyle(fontSize: 10, fontWeight: FontWeight.bold),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
invoice.customer.formalName,
style: const TextStyle(fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("No: ${invoice.invoiceNumber}"),
Text(dateFormatter.format(invoice.date), style: const TextStyle(fontSize: 12)),
],
),
trailing: Text(
"¥${amountFormatter.format(invoice.totalAmount)}",
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.indigo,
fontSize: 16,
),
),
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => InvoiceDetailPage(invoice: invoice),
),
);
_loadInvoices();
},
onLongPress: () => _deleteInvoice(invoice),
),
);
},
),
);
}
}

View file

@ -1,12 +1,14 @@
// lib/screens/invoice_input_screen.dart
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../models/customer_model.dart'; import '../models/customer_model.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';
import '../services/master_repository.dart';
import 'customer_picker_modal.dart'; import 'customer_picker_modal.dart';
/// ///
class InvoiceInputForm extends StatefulWidget { class InvoiceInputForm extends StatefulWidget {
final Function(Invoice invoice, String filePath) onInvoiceGenerated; final Function(Invoice invoice, String filePath) onInvoiceGenerated;
@ -22,25 +24,37 @@ class InvoiceInputForm extends StatefulWidget {
class _InvoiceInputFormState extends State<InvoiceInputForm> { class _InvoiceInputFormState extends State<InvoiceInputForm> {
final _clientController = TextEditingController(); final _clientController = TextEditingController();
final _amountController = TextEditingController(text: "250000"); final _amountController = TextEditingController(text: "250000");
final _repository = InvoiceRepository(); final _invoiceRepository = InvoiceRepository();
String _status = "取引先を選択してPDFを生成してください"; final _masterRepository = MasterRepository();
DocumentType _selectedType = DocumentType.invoice; //
String _status = "取引先を選択してPDFを生成してください";
List<Customer> _customerBuffer = []; List<Customer> _customerBuffer = [];
Customer? _selectedCustomer; Customer? _selectedCustomer;
bool _isLoading = true;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_selectedCustomer = Customer( _loadInitialData();
id: const Uuid().v4(), }
displayName: "佐々木製作所",
formalName: "株式会社 佐々木製作所",
);
_customerBuffer.add(_selectedCustomer!);
_clientController.text = _selectedCustomer!.formalName;
// PDFを掃除する ///
_repository.cleanupOrphanedPdfs().then((count) { Future<void> _loadInitialData() async {
setState(() => _isLoading = true);
final savedCustomers = await _masterRepository.loadCustomers();
setState(() {
_customerBuffer = savedCustomers;
if (_customerBuffer.isNotEmpty) {
_selectedCustomer = _customerBuffer.first;
_clientController.text = _selectedCustomer!.formalName;
}
_isLoading = false;
});
_invoiceRepository.cleanupOrphanedPdfs().then((count) {
if (count > 0) { if (count > 0) {
debugPrint('Cleaned up $count orphaned PDF files.'); debugPrint('Cleaned up $count orphaned PDF files.');
} }
@ -54,6 +68,7 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
super.dispose(); super.dispose();
} }
///
Future<void> _openCustomerPicker() async { Future<void> _openCustomerPicker() async {
setState(() => _status = "顧客マスターを開いています..."); setState(() => _status = "顧客マスターを開いています...");
@ -65,10 +80,12 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
heightFactor: 0.9, heightFactor: 0.9,
child: CustomerPickerModal( child: CustomerPickerModal(
existingCustomers: _customerBuffer, existingCustomers: _customerBuffer,
onCustomerSelected: (customer) { onCustomerSelected: (customer) async {
setState(() { setState(() {
bool exists = _customerBuffer.any((c) => c.id == customer.id); int index = _customerBuffer.indexWhere((c) => c.id == customer.id);
if (!exists) { if (index != -1) {
_customerBuffer[index] = customer;
} else {
_customerBuffer.add(customer); _customerBuffer.add(customer);
} }
@ -76,13 +93,26 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
_clientController.text = customer.formalName; _clientController.text = customer.formalName;
_status = "${customer.formalName}」を選択しました"; _status = "${customer.formalName}」を選択しました";
}); });
Navigator.pop(context);
await _masterRepository.saveCustomers(_customerBuffer);
if (mounted) Navigator.pop(context);
},
onCustomerDeleted: (customer) async {
setState(() {
_customerBuffer.removeWhere((c) => c.id == customer.id);
if (_selectedCustomer?.id == customer.id) {
_selectedCustomer = null;
_clientController.clear();
}
});
await _masterRepository.saveCustomers(_customerBuffer);
}, },
), ),
), ),
); );
} }
/// PDFを生成して詳細画面へ進む
Future<void> _handleInitialGenerate() async { Future<void> _handleInitialGenerate() async {
if (_selectedCustomer == null) { if (_selectedCustomer == null) {
setState(() => _status = "取引先を選択してください"); setState(() => _status = "取引先を選択してください");
@ -93,7 +123,7 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
final initialItems = [ final initialItems = [
InvoiceItem( InvoiceItem(
description: "ご請求", description: "${_selectedType.label}",
quantity: 1, quantity: 1,
unitPrice: unitPrice, unitPrice: unitPrice,
) )
@ -103,19 +133,17 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
customer: _selectedCustomer!, customer: _selectedCustomer!,
date: DateTime.now(), date: DateTime.now(),
items: initialItems, items: initialItems,
type: _selectedType,
); );
setState(() => _status = "A4請求書を生成中..."); setState(() => _status = "${_selectedType.label}を生成中...");
final path = await generateInvoicePdf(invoice); final path = await generateInvoicePdf(invoice);
if (path != null) { if (path != null) {
final updatedInvoice = invoice.copyWith(filePath: path); final updatedInvoice = invoice.copyWith(filePath: path);
await _invoiceRepository.saveInvoice(updatedInvoice);
// DBに保存
await _repository.saveInvoice(updatedInvoice);
widget.onInvoiceGenerated(updatedInvoice, path); widget.onInvoiceGenerated(updatedInvoice, path);
setState(() => _status = "PDFを生成しDBに登録しました。"); setState(() => _status = "${_selectedType.label}を生成しDBに登録しました。");
} else { } else {
setState(() => _status = "PDFの生成に失敗しました"); setState(() => _status = "PDFの生成に失敗しました");
} }
@ -123,76 +151,104 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
return Padding( return Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: SingleChildScrollView( child: SingleChildScrollView(
child: Column(children: [ child: Column(
const Text( crossAxisAlignment: CrossAxisAlignment.start,
"ステップ1: 宛先と基本金額の設定", children: [
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.blueGrey), const Text(
), "帳票の種類を選択",
const SizedBox(height: 16), style: TextStyle(fontWeight: FontWeight.bold, color: Colors.blueGrey),
Row(children: [ ),
Expanded( const SizedBox(height: 8),
child: TextField( Wrap(
controller: _clientController, spacing: 8.0,
readOnly: true, children: DocumentType.values.map((type) {
onTap: _openCustomerPicker, return ChoiceChip(
decoration: const InputDecoration( label: Text(type.label),
labelText: "取引先名 (タップして選択)", selected: _selectedType == type,
hintText: "電話帳から取り込むか、マスターから選択", onSelected: (selected) {
prefixIcon: Icon(Icons.business), if (selected) {
border: OutlineInputBorder(), setState(() => _selectedType = type);
}
},
selectedColor: Colors.indigo.shade100,
);
}).toList(),
),
const SizedBox(height: 24),
const Text(
"宛先と基本金額の設定",
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.blueGrey),
),
const SizedBox(height: 12),
Row(children: [
Expanded(
child: TextField(
controller: _clientController,
readOnly: true,
onTap: _openCustomerPicker,
decoration: const InputDecoration(
labelText: "取引先名 (タップして選択)",
hintText: "マスターから選択または電話帳から取り込み",
prefixIcon: Icon(Icons.business),
border: OutlineInputBorder(),
),
), ),
), ),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.person_add_alt_1, color: Colors.indigo, size: 40),
onPressed: _openCustomerPicker,
tooltip: "顧客を選択・登録",
),
]),
const SizedBox(height: 16),
TextField(
controller: _amountController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: "基本金額 (税抜)",
hintText: "明細の1行目として登録されます",
prefixIcon: Icon(Icons.currency_yen),
border: OutlineInputBorder(),
),
), ),
const SizedBox(width: 8), const SizedBox(height: 24),
IconButton( ElevatedButton.icon(
icon: const Icon(Icons.person_add_alt_1, color: Colors.indigo, size: 40), onPressed: _handleInitialGenerate,
onPressed: _openCustomerPicker, icon: const Icon(Icons.description),
tooltip: "顧客を選択・登録", label: Text("${_selectedType.label}を作成して詳細編集へ"),
style: ElevatedButton.styleFrom(
minimumSize: const Size(double.infinity, 60),
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
elevation: 4,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
), ),
]), const SizedBox(height: 24),
const SizedBox(height: 16), Container(
TextField( width: double.infinity,
controller: _amountController, padding: const EdgeInsets.all(12),
keyboardType: TextInputType.number, decoration: BoxDecoration(
decoration: const InputDecoration( color: Colors.grey[100],
labelText: "基本金額 (税抜)", borderRadius: BorderRadius.circular(8),
hintText: "明細の1行目として登録されます", border: Border.all(color: Colors.grey.shade300),
prefixIcon: Icon(Icons.currency_yen), ),
border: OutlineInputBorder(), child: Text(
_status,
style: const TextStyle(fontSize: 12, color: Colors.black54),
textAlign: TextAlign.center,
),
), ),
), ],
const SizedBox(height: 24), ),
ElevatedButton.icon(
onPressed: _handleInitialGenerate,
icon: const Icon(Icons.description),
label: const Text("A4請求書を作成して詳細編集へ"),
style: ElevatedButton.styleFrom(
minimumSize: const Size(double.infinity, 60),
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
elevation: 4,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
const SizedBox(height: 24),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
child: Text(
_status,
style: const TextStyle(fontSize: 12, color: Colors.black54),
textAlign: TextAlign.center,
),
),
]),
), ),
); );
} }

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../data/product_master.dart'; import '../data/product_master.dart';
import '../models/invoice_models.dart'; import '../models/invoice_models.dart';
import '../services/master_repository.dart';
/// ///
class ProductPickerModal extends StatefulWidget { class ProductPickerModal extends StatefulWidget {
@ -17,19 +18,31 @@ class ProductPickerModal extends StatefulWidget {
} }
class _ProductPickerModalState extends State<ProductPickerModal> { class _ProductPickerModalState extends State<ProductPickerModal> {
final MasterRepository _masterRepository = MasterRepository();
String _searchQuery = ""; String _searchQuery = "";
List<Product> _masterProducts = []; List<Product> _masterProducts = [];
List<Product> _filteredProducts = []; List<Product> _filteredProducts = [];
String _selectedCategory = "すべて"; String _selectedCategory = "すべて";
bool _isLoading = true;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// ProductMasterの初期データを使用 _loadProducts();
_masterProducts = List.from(ProductMaster.products);
_filterProducts();
} }
///
Future<void> _loadProducts() async {
setState(() => _isLoading = true);
final products = await _masterRepository.loadProducts();
setState(() {
_masterProducts = products;
_isLoading = false;
_filterProducts();
});
}
///
void _filterProducts() { void _filterProducts() {
setState(() { setState(() {
_filteredProducts = _masterProducts.where((product) { _filteredProducts = _masterProducts.where((product) {
@ -83,34 +96,34 @@ class _ProductPickerModalState extends State<ProductPickerModal> {
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")), TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
ElevatedButton( ElevatedButton(
onPressed: () { onPressed: () async {
final String name = nameController.text.trim(); final String name = nameController.text.trim();
final int price = int.tryParse(priceController.text) ?? 0; final int price = int.tryParse(priceController.text) ?? 0;
if (name.isEmpty) return; if (name.isEmpty) return;
setState(() { Product updatedProduct;
if (existingProduct != null) { if (existingProduct != null) {
// updatedProduct = existingProduct.copyWith(
final index = _masterProducts.indexWhere((p) => p.id == existingProduct.id); name: name,
if (index != -1) { defaultUnitPrice: price,
_masterProducts[index] = existingProduct.copyWith( category: categoryController.text.trim(),
name: name, );
defaultUnitPrice: price, } else {
category: categoryController.text.trim(), updatedProduct = Product(
); id: idController.text.isEmpty ? const Uuid().v4().substring(0, 8) : idController.text,
} name: name,
} else { defaultUnitPrice: price,
// category: categoryController.text.trim(),
_masterProducts.add(Product( );
id: idController.text.isEmpty ? const Uuid().v4().substring(0, 8) : idController.text, }
name: name,
defaultUnitPrice: price, //
category: categoryController.text.trim(), await _masterRepository.upsertProduct(updatedProduct);
));
} if (mounted) {
_filterProducts(); Navigator.pop(context);
}); _loadProducts(); //
Navigator.pop(context); }
}, },
child: const Text("保存"), child: const Text("保存"),
), ),
@ -129,12 +142,15 @@ class _ProductPickerModalState extends State<ProductPickerModal> {
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")), TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
TextButton( TextButton(
onPressed: () { onPressed: () async {
setState(() { setState(() {
_masterProducts.removeWhere((p) => p.id == product.id); _masterProducts.removeWhere((p) => p.id == product.id);
_filterProducts();
}); });
Navigator.pop(context); await _masterRepository.saveProducts(_masterProducts);
if (mounted) {
Navigator.pop(context);
_filterProducts();
}
}, },
child: const Text("削除する", style: TextStyle(color: Colors.red)), child: const Text("削除する", style: TextStyle(color: Colors.red)),
), ),
@ -145,7 +161,10 @@ class _ProductPickerModalState extends State<ProductPickerModal> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// if (_isLoading) {
return const Material(child: Center(child: CircularProgressIndicator()));
}
final dynamicCategories = ["すべて", ..._masterProducts.map((p) => p.category ?? 'その他').toSet().toList()]; final dynamicCategories = ["すべて", ..._masterProducts.map((p) => p.category ?? 'その他').toSet().toList()];
return Material( return Material(

View file

@ -38,10 +38,17 @@ class InvoiceRepository {
// //
final index = all.indexWhere((i) => i.invoiceNumber == invoice.invoiceNumber); final index = all.indexWhere((i) => i.invoiceNumber == invoice.invoiceNumber);
if (index != -1) { if (index != -1) {
// PDFの掃除 final oldInvoice = all[index];
final oldPath = all[index].filePath; final oldPath = oldInvoice.filePath;
//
if (oldPath != null && oldPath != invoice.filePath) { if (oldPath != null && oldPath != invoice.filePath) {
await _deletePhysicalFile(oldPath); //
if (!oldInvoice.isShared) {
await _deletePhysicalFile(oldPath);
} else {
print('Skipping deletion of shared file: $oldPath');
}
} }
all[index] = invoice; all[index] = invoice;
} else { } else {
@ -80,8 +87,11 @@ class InvoiceRepository {
} }
/// DBに登録されていないPDFファイル /// DBに登録されていないPDFファイル
/// DBエントリーのパスは
Future<int> cleanupOrphanedPdfs() async { Future<int> cleanupOrphanedPdfs() async {
final List<Invoice> all = await getAllInvoices(); final List<Invoice> all = await getAllInvoices();
// DBに登録されている全ての有効なパス
final Set<String> registeredPaths = all final Set<String> registeredPaths = all
.where((i) => i.filePath != null) .where((i) => i.filePath != null)
.map((i) => i.filePath!) .map((i) => i.filePath!)
@ -95,7 +105,7 @@ class InvoiceRepository {
for (var entity in files) { for (var entity in files) {
if (entity is File && entity.path.endsWith('.pdf')) { if (entity is File && entity.path.endsWith('.pdf')) {
// DBに登録されていないPDFは削除 // DBのどの請求データ
if (!registeredPaths.contains(entity.path)) { if (!registeredPaths.contains(entity.path)) {
await entity.delete(); await entity.delete();
deletedCount++; deletedCount++;

View file

@ -0,0 +1,150 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import '../models/customer_model.dart';
import '../models/company_model.dart'; // Companyモデルをインポート
import '../data/product_master.dart';
///
class MasterRepository {
static const String _customerFileName = 'customers_master.json';
static const String _productFileName = 'products_master.json';
static const String _companyFileName = 'company_info.json'; //
///
Future<File> _getCustomerFile() async {
final directory = await getApplicationDocumentsDirectory();
return File('${directory.path}/$_customerFileName');
}
///
Future<File> _getProductFile() async {
final directory = await getApplicationDocumentsDirectory();
return File('${directory.path}/$_productFileName');
}
///
Future<File> _getCompanyFile() async {
final directory = await getApplicationDocumentsDirectory();
return File('${directory.path}/$_companyFileName');
}
// --- ---
///
Future<List<Customer>> loadCustomers() async {
try {
final file = await _getCustomerFile();
if (!await file.exists()) return [];
final String content = await file.readAsString();
final List<dynamic> jsonList = json.decode(content);
return jsonList.map((j) => Customer.fromJson(j)).toList();
} catch (e) {
debugPrint('Customer Master Loading Error: $e');
return [];
}
}
///
Future<void> saveCustomers(List<Customer> customers) async {
try {
final file = await _getCustomerFile();
final String encoded = json.encode(customers.map((c) => c.toJson()).toList());
await file.writeAsString(encoded);
} catch (e) {
debugPrint('Customer Master Saving Error: $e');
}
}
///
Future<void> upsertCustomer(Customer customer) async {
final customers = await loadCustomers();
final index = customers.indexWhere((c) => c.id == customer.id);
if (index != -1) {
customers[index] = customer;
} else {
customers.add(customer);
}
await saveCustomers(customers);
}
// --- ---
///
/// ProductMasterに定義された初期データを返す
Future<List<Product>> loadProducts() async {
try {
final file = await _getProductFile();
if (!await file.exists()) {
// ProductMasterのハードコードされたリストを返す
return List.from(ProductMaster.products);
}
final String content = await file.readAsString();
final List<dynamic> jsonList = json.decode(content);
return jsonList.map((j) => Product.fromJson(j)).toList();
} catch (e) {
debugPrint('Product Master Loading Error: $e');
return List.from(ProductMaster.products); //
}
}
///
Future<void> saveProducts(List<Product> products) async {
try {
final file = await _getProductFile();
final String encoded = json.encode(products.map((p) => p.toJson()).toList());
await file.writeAsString(encoded);
} catch (e) {
debugPrint('Product Master Saving Error: $e');
}
}
///
Future<void> upsertProduct(Product product) async {
final products = await loadProducts();
final index = products.indexWhere((p) => p.id == product.id);
if (index != -1) {
products[index] = product;
} else {
products.add(product);
}
await saveProducts(products);
}
// --- ---
///
/// Company.defaultCompany
Future<Company> loadCompany() async {
try {
final file = await _getCompanyFile();
if (!await file.exists()) {
return Company.defaultCompany;
}
final String content = await file.readAsString();
final Map<String, dynamic> jsonMap = json.decode(content);
return Company.fromJson(jsonMap);
} catch (e) {
debugPrint('Company Info Loading Error: $e');
return Company.defaultCompany; //
}
}
///
Future<void> saveCompany(Company company) async {
try {
final file = await _getCompanyFile();
final String encoded = json.encode(company.toJson());
await file.writeAsString(encoded);
} catch (e) {
debugPrint('Company Info Saving Error: $e');
}
}
}

View file

@ -1,3 +1,4 @@
// lib/services/pdf_generator.dart
import 'dart:io'; import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart' show debugPrint; import 'package:flutter/material.dart' show debugPrint;
@ -7,9 +8,13 @@ import 'package:pdf/widgets.dart' as pw;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:printing/printing.dart';
import '../models/invoice_models.dart'; import '../models/invoice_models.dart';
import '../models/company_model.dart'; // Companyモデルをインポート
import 'master_repository.dart'; // MasterRepositoryをインポート
/// A4サイズのプロフェッショナルな請求書PDFを生成し /// A4サイズのプロフェッショナルな帳票PDFを生成し
/// DocumentTypeに対応
Future<String?> generateInvoicePdf(Invoice invoice) async { Future<String?> generateInvoicePdf(Invoice invoice) async {
try { try {
final pdf = pw.Document(); final pdf = pw.Document();
@ -19,8 +24,16 @@ Future<String?> generateInvoicePdf(Invoice invoice) async {
final ttf = pw.Font.ttf(fontData); final ttf = pw.Font.ttf(fontData);
final boldTtf = pw.Font.ttf(fontData); // IPAexGはウェイトが1つなので同じものを使用 final boldTtf = pw.Font.ttf(fontData); // IPAexGはウェイトが1つなので同じものを使用
//
final MasterRepository masterRepository = MasterRepository();
final Company company = await masterRepository.loadCompany();
final dateFormatter = DateFormat('yyyy年MM月dd日'); final dateFormatter = DateFormat('yyyy年MM月dd日');
final amountFormatter = NumberFormat("#,###"); final amountFormatter = NumberFormat("¥#,###"); //
//
final String docTitle = invoice.type.label;
final String honorific = " 御中"; // (estimateでもinvoiceでも共通化)
pdf.addPage( pdf.addPage(
pw.MultiPage( pw.MultiPage(
@ -34,11 +47,11 @@ Future<String?> generateInvoicePdf(Invoice invoice) async {
child: pw.Row( child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [ children: [
pw.Text("請求書", style: pw.TextStyle(fontSize: 28, fontWeight: pw.FontWeight.bold)), pw.Text(docTitle, style: pw.TextStyle(fontSize: 28, fontWeight: pw.FontWeight.bold)),
pw.Column( pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.end, crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [ children: [
pw.Text("請求番号: ${invoice.invoiceNumber}"), pw.Text("管理番号: ${invoice.invoiceNumber}"),
pw.Text("発行日: ${dateFormatter.format(invoice.date)}"), pw.Text("発行日: ${dateFormatter.format(invoice.date)}"),
], ],
), ),
@ -55,15 +68,17 @@ Future<String?> generateInvoicePdf(Invoice invoice) async {
child: pw.Column( child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start, crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [ children: [
pw.Container( pw.Text("${invoice.customer.formalName}$honorific",
decoration: const pw.BoxDecoration( style: const pw.TextStyle(fontSize: 18)),
border: pw.Border(bottom: pw.BorderSide(width: 1)), if (invoice.customer.department != null && invoice.customer.department!.isNotEmpty)
pw.Padding(
padding: const pw.EdgeInsets.only(top: 4),
child: pw.Text(invoice.customer.department!),
), ),
child: pw.Text(invoice.customer.invoiceName,
style: const pw.TextStyle(fontSize: 18)),
),
pw.SizedBox(height: 10), pw.SizedBox(height: 10),
pw.Text("下記の通り、ご請求申し上げます。"), pw.Text(invoice.type == DocumentType.estimate
? "下記の通り、御見積申し上げます。"
: "下記の通り、ご請求申し上げます。"),
], ],
), ),
), ),
@ -71,10 +86,11 @@ Future<String?> generateInvoicePdf(Invoice invoice) async {
child: pw.Column( child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.end, crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [ children: [
pw.Text("自社名が入ります", style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)), pw.Text(company.formalName, style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)),
pw.Text("〒000-0000"), if (company.zipCode != null && company.zipCode!.isNotEmpty) pw.Text(company.zipCode!),
pw.Text("住所がここに入ります"), if (company.address != null && company.address!.isNotEmpty) pw.Text(company.address!),
pw.Text("TEL: 00-0000-0000"), if (company.tel != null && company.tel!.isNotEmpty) pw.Text(company.tel!),
if (company.registrationNumber != null && company.registrationNumber!.isNotEmpty) pw.Text(company.registrationNumber! ),
], ],
), ),
), ),
@ -89,8 +105,8 @@ Future<String?> generateInvoicePdf(Invoice invoice) async {
child: pw.Row( child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [ children: [
pw.Text("ご請求金額合計 (税込)", style: const pw.TextStyle(fontSize: 16)), pw.Text("${docTitle}金額合計 (税込)", style: const pw.TextStyle(fontSize: 16)),
pw.Text("${amountFormatter.format(invoice.totalAmount)} -", pw.Text("${amountFormatter.format(invoice.totalAmount)} -",
style: pw.TextStyle(fontSize: 20, fontWeight: pw.FontWeight.bold)), style: pw.TextStyle(fontSize: 20, fontWeight: pw.FontWeight.bold)),
], ],
), ),
@ -135,7 +151,7 @@ Future<String?> generateInvoicePdf(Invoice invoice) async {
_buildSummaryRow("小計 (税抜)", amountFormatter.format(invoice.subtotal)), _buildSummaryRow("小計 (税抜)", amountFormatter.format(invoice.subtotal)),
_buildSummaryRow("消費税 (10%)", amountFormatter.format(invoice.tax)), _buildSummaryRow("消費税 (10%)", amountFormatter.format(invoice.tax)),
pw.Divider(), pw.Divider(),
_buildSummaryRow("合計", "${amountFormatter.format(invoice.totalAmount)}", isBold: true), _buildSummaryRow("合計", amountFormatter.format(invoice.totalAmount), isBold: true),
], ],
), ),
), ),
@ -150,8 +166,7 @@ Future<String?> generateInvoicePdf(Invoice invoice) async {
width: double.infinity, width: double.infinity,
padding: const pw.EdgeInsets.all(8), padding: const pw.EdgeInsets.all(8),
decoration: pw.BoxDecoration(border: pw.Border.all(color: PdfColors.grey400)), decoration: pw.BoxDecoration(border: pw.Border.all(color: PdfColors.grey400)),
child: pw.Text(invoice.notes!), child: pw.Text(invoice.notes!)),
),
], ],
], ],
footer: (context) => pw.Container( footer: (context) => pw.Container(
@ -165,17 +180,17 @@ Future<String?> generateInvoicePdf(Invoice invoice) async {
), ),
); );
//
final Uint8List bytes = await pdf.save(); final Uint8List bytes = await pdf.save();
final String hash = sha256.convert(bytes).toString().substring(0, 8); final String hash = sha256.convert(bytes).toString().substring(0, 8);
final String dateFileStr = DateFormat('yyyyMMdd').format(invoice.date); final String dateFileStr = DateFormat('yyyyMMdd').format(invoice.date);
String fileName = "Invoice_${dateFileStr}_${invoice.customer.formalName}_$hash.pdf"; String fileName = "${invoice.type.name}_${dateFileStr}_${invoice.customer.formalName}_$hash.pdf";
final directory = await getExternalStorageDirectory(); final directory = await getExternalStorageDirectory();
if (directory == null) return null; if (directory == null) return null;
final file = File("${directory.path}/$fileName"); final file = File("${directory.path}/$fileName");
await file.writeAsBytes(bytes); await file.writeAsBytes(bytes);
return file.path; return file.path;
} catch (e) { } catch (e) {
debugPrint("PDF Generation Error: $e"); debugPrint("PDF Generation Error: $e");
@ -183,6 +198,88 @@ Future<String?> generateInvoicePdf(Invoice invoice) async {
} }
} }
/// 58mmレシートPDFを生成して印刷ダイアログを表示する
Future<void> printThermalReceipt(Invoice invoice) async {
try {
final fontData = await rootBundle.load("assets/fonts/ipaexg.ttf");
final ttf = pw.Font.ttf(fontData);
final amountFormatter = NumberFormat("¥#,###"); //
//
final MasterRepository masterRepository = MasterRepository();
final Company company = await masterRepository.loadCompany();
final doc = pw.Document();
doc.addPage(
pw.Page(
// 58mm幅のサーマルプリンタ向け設定 (164pt)
pageFormat: const PdfPageFormat(58 * PdfPageFormat.mm, double.infinity, marginAll: 2 * PdfPageFormat.mm),
theme: pw.ThemeData.withFont(base: ttf),
build: (pw.Context context) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Center(
child: pw.Text(invoice.type.label, style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold)),
),
pw.SizedBox(height: 5),
pw.Text("${invoice.customer.formalName}", style: const pw.TextStyle(fontSize: 10)),
pw.Divider(thickness: 1, borderStyle: pw.BorderStyle.dashed),
pw.SizedBox(height: 5),
pw.Center(
child: pw.Text(amountFormatter.format(invoice.totalAmount),
style: pw.TextStyle(fontSize: 18, fontWeight: pw.FontWeight.bold)),
),
pw.Center(child: pw.Text("(うち消費税 ${amountFormatter.format(invoice.tax)})", style: const pw.TextStyle(fontSize: 8))),
pw.SizedBox(height: 10),
pw.Text("但し、お品代として", style: const pw.TextStyle(fontSize: 9)),
pw.Text("上記正に領収いたしました", style: const pw.TextStyle(fontSize: 9)),
pw.SizedBox(height: 10),
//
pw.Text("--- 明細 ---\n", style: const pw.TextStyle(fontSize: 8)),
...invoice.items.map((item) => pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Expanded(child: pw.Text(item.description, style: const pw.TextStyle(fontSize: 8))),
pw.Text("x${item.quantity} ", style: const pw.TextStyle(fontSize: 8)),
pw.Text(amountFormatter.format(item.subtotal), style: const pw.TextStyle(fontSize: 8)),
],
)),
pw.Divider(thickness: 0.5),
pw.SizedBox(height: 5),
pw.Align(
alignment: pw.Alignment.centerRight,
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
pw.Text(company.formalName, style: const pw.TextStyle(fontSize: 9)),
pw.Text(DateFormat('yyyy/MM/dd HH:mm').format(invoice.date), style: const pw.TextStyle(fontSize: 7)),
pw.Text("No: ${invoice.invoiceNumber}", style: const pw.TextStyle(fontSize: 7)),
],
),
),
pw.SizedBox(height: 10),
pw.Center(child: pw.Text("ありがとうございました", style: const pw.TextStyle(fontSize: 8))),
pw.SizedBox(height: 20), //
],
);
},
),
);
//
await Printing.layoutPdf(
onLayout: (PdfPageFormat format) async => doc.save(),
name: "${invoice.type.name}_${invoice.invoiceNumber}",
);
} catch (e) {
debugPrint("Thermal Print Error: $e");
}
}
pw.Widget _buildSummaryRow(String label, String value, {bool isBold = false}) { pw.Widget _buildSummaryRow(String label, String value, {bool isBold = false}) {
final style = pw.TextStyle(fontSize: 12, fontWeight: isBold ? pw.FontWeight.bold : null); final style = pw.TextStyle(fontSize: 12, fontWeight: isBold ? pw.FontWeight.bold : null);
return pw.Padding( return pw.Padding(

View file

@ -6,9 +6,13 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <printing/printing_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h> #include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) printing_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin");
printing_plugin_register_with_registrar(printing_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);

View file

@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
printing
url_launcher_linux url_launcher_linux
) )

View file

@ -5,10 +5,12 @@
import FlutterMacOS import FlutterMacOS
import Foundation import Foundation
import printing
import share_plus import share_plus
import url_launcher_macos import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
} }

View file

@ -176,6 +176,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
http:
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image: image:
dependency: transitive dependency: transitive
description: description:
@ -360,6 +376,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.11.3" version: "3.11.3"
pdf_widget_wrapper:
dependency: transitive
description:
name: pdf_widget_wrapper
sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5
url: "https://pub.dev"
source: hosted
version: "1.0.4"
permission_handler: permission_handler:
dependency: "direct main" dependency: "direct main"
description: description:
@ -440,6 +464,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.3" version: "6.0.3"
printing:
dependency: "direct main"
description:
name: printing
sha256: "482cd5a5196008f984bb43ed0e47cbfdca7373490b62f3b27b3299275bf22a93"
url: "https://pub.dev"
source: hosted
version: "5.14.2"
pub_semver: pub_semver:
dependency: transitive dependency: transitive
description: description:
@ -598,7 +630,7 @@ packages:
source: hosted source: hosted
version: "3.1.5" version: "3.1.5"
uuid: uuid:
dependency: transitive dependency: "direct main"
description: description:
name: uuid name: uuid
sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8

View file

@ -43,6 +43,8 @@ dependencies:
share_plus: ^12.0.1 share_plus: ^12.0.1
url_launcher: ^6.3.2 url_launcher: ^6.3.2
open_filex: ^4.7.0 open_filex: ^4.7.0
printing: ^5.13.2
uuid: ^4.5.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View file

@ -7,12 +7,15 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <permission_handler_windows/permission_handler_windows_plugin.h> #include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <printing/printing_plugin.h>
#include <share_plus/share_plus_windows_plugin_c_api.h> #include <share_plus/share_plus_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h> #include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
PermissionHandlerWindowsPluginRegisterWithRegistrar( PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
PrintingPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PrintingPlugin"));
SharePlusWindowsPluginCApiRegisterWithRegistrar( SharePlusWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar( UrlLauncherWindowsRegisterWithRegistrar(

View file

@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
permission_handler_windows permission_handler_windows
printing
share_plus share_plus
url_launcher_windows url_launcher_windows
) )