Compare commits

..

1 commit
main ... ollama

Author SHA1 Message Date
user
4f9c24c1a2 ollama/gpt-oss:20bにスイッチ 2026-02-08 10:55:57 +09:00
20 changed files with 425 additions and 1393 deletions

View file

@ -4,6 +4,9 @@ A new Flutter project.
## Getting Started ## Getting Started
!! 日本語が主体のアプリです。アプリの基本言語は日本語です。コメントも日本語と英語併記です。!!
!! 可読性は重視しません。AIの開発効率を最大に上げて下さい。!!
This project is a starting point for a Flutter application. This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project: A few resources to get you started if this is your first Flutter project:
@ -14,3 +17,28 @@ A few resources to get you started if this is your first Flutter project:
For help getting started with Flutter development, view the For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials, [online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference. samples, guidance on mobile development, and a full API reference.
## 販売アシスト1号の機能
販売アシスト1号は、営業マンの販売業務を支援するためのアプリケーションです。主な機能は以下の通りです。
- ファイル名の規則 "{date}({請求等}){顧客名}_{件名}_{金額カンマ付}円_{sha256}" 20251202(請求)佐々木製作所_あの件_20,000円_25ab85cc9988
- 伝票(件名)の管理
- 管理ではファイルの様にオブジェクトとして管理します。
- 顧客マスター管理
- 商品マスター管理
- 見積納品請求領収証の作成
- 見積納品請求領収証の一覧表示
- 見積納品請求領収証のPDF出力
- PDFの管理
- GPSと顧客マスターを連携し座標の履歴を記録する機能
- フォーム入力時にGPS情報で自動的に顧客マスターから候補を選出する機能
- 入力時にQRコードやOCRをGoogleレンズを使ってアシストします
- レシート印刷するポータブルプリンタを使って領収証印刷する機能
- 印刷時にQRコードを印刷する機能
- Googleドライブにバックアップする機能
- 未来の機能(今は実装しない可能にしておく)odooとの同期・連携 伝票単位で同期
アプリの基本言語は日本語です。コメントも日本語と英語併記です。可読性は重視しません。AIの開発効率を最大に上げるものです。
表示言語は日本語です。環境により英語を表示する機能は未来に実装予定です。
見積・納品・請求・領収証はフォームがほぼ同じですが、項目が時間と共に変化するので件名が同じでも明細は異なる場合があります。
伝票はsqliteで管理します。

View file

@ -1,19 +1,17 @@
// lib/main.dart // lib/main.dart
// version: 1.4.3c (Bug Fix: PDF layout error) - Refactored for modularity and history management // version: 1.4.3c (Bug Fix: PDF layout error) - Refactored for modularity
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'screens/pdf_list_screen.dart'; // PDF一覧画面
// --- --- // --- ---
import 'models/invoice_models.dart'; import 'models/invoice_models.dart'; // Invoice, InvoiceItem
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});
@ -25,87 +23,30 @@ class MyApp extends StatelessWidget {
primarySwatch: Colors.blueGrey, primarySwatch: Colors.blueGrey,
visualDensity: VisualDensity.adaptivePlatformDensity, visualDensity: VisualDensity.adaptivePlatformDensity,
useMaterial3: true, useMaterial3: true,
fontFamily: 'IPAexGothic',
), ),
home: const MainNavigationShell(), home: const InvoiceFlowScreen(),
); );
} }
} }
/// class InvoiceFlowScreen extends StatefulWidget {
class MainNavigationShell extends StatefulWidget { const InvoiceFlowScreen({super.key});
const MainNavigationShell({super.key});
@override @override
State<MainNavigationShell> createState() => _MainNavigationShellState(); State<InvoiceFlowScreen> createState() => _InvoiceFlowScreenState();
} }
class _MainNavigationShellState extends State<MainNavigationShell> { class _InvoiceFlowScreenState extends State<InvoiceFlowScreen> {
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(BuildContext context, Invoice generatedInvoice, String filePath) { void _handleInvoiceGenerated(Invoice generatedInvoice, String filePath) {
// PDF生成DB保存後に詳細ページへ遷移 setState(() {
_lastGeneratedInvoice = generatedInvoice;
});
//
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@ -114,31 +55,30 @@ class InvoiceFlowScreen extends StatelessWidget {
); );
} }
//
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, actions: [
IconButton(
icon: const Icon(Icons.picture_as_pdf),
tooltip: 'PDF一覧',
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const PdfListScreen(),
),
);
},
),
],
), ),
// //
body: InvoiceInputForm( body: InvoiceInputForm(
onInvoiceGenerated: (invoice, path) => _handleInvoiceGenerated(context, invoice, path), onInvoiceGenerated: _handleInvoiceGenerated,
), ),
); );
} }

View file

@ -1,106 +0,0 @@
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,47 +1,31 @@
// 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 * (isDiscount ? -1 : 1); int get subtotal => quantity * unitPrice;
// //
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,
); );
} }
@ -51,7 +35,6 @@ class InvoiceItem {
'description': description, 'description': description,
'quantity': quantity, 'quantity': quantity,
'unit_price': unitPrice, 'unit_price': unitPrice,
'is_discount': isDiscount,
}; };
} }
@ -61,12 +44,11 @@ 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;
@ -74,8 +56,6 @@ 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,
@ -84,8 +64,6 @@ 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);
// //
@ -114,8 +92,6 @@ 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,
@ -124,23 +100,19 @@ 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("Number,$invoiceNumber"); sb.writeln("Invoice 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,IsDiscount"); // isDiscountを追加 sb.writeln("Description,Quantity,UnitPrice,Subtotal");
for (var item in items) { for (var item in items) {
sb.writeln("${item.description},${item.quantity},${item.unitPrice},${item.subtotal},${item.isDiscount ? 'Yes' : 'No'}"); sb.writeln("${item.description},${item.quantity},${item.unitPrice},${item.subtotal}");
} }
return sb.toString(); return sb.toString();
} }
@ -154,8 +126,6 @@ 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の名前で保存
}; };
} }
@ -169,12 +139,7 @@ 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'] == 'null') ? null : json['notes'] as String?, // 'null' notes: json['notes'] as String?,
isShared: json['is_shared'] ?? false,
type: DocumentType.values.firstWhere(
(e) => e.name == (json['type'] ?? 'invoice'),
orElse: () => DocumentType.invoice,
),
); );
} }
} }

View file

@ -1,206 +0,0 @@
// 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,9 +4,8 @@ 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 {
@ -25,9 +24,6 @@ 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() {
@ -44,7 +40,6 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
void dispose() { void dispose() {
_formalNameController.dispose(); _formalNameController.dispose();
_notesController.dispose(); _notesController.dispose();
_scrollController.dispose();
super.dispose(); super.dispose();
} }
@ -52,16 +47,6 @@ 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) {
@ -70,6 +55,25 @@ 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) {
@ -87,64 +91,36 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
final updatedInvoice = _currentInvoice.copyWith( final updatedInvoice = _currentInvoice.copyWith(
customer: updatedCustomer, customer: updatedCustomer,
items: _items, items: _items,
notes: _notesController.text.trim(), notes: _notesController.text,
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 = finalInvoice; _currentInvoice = updatedInvoice.copyWith(filePath: newPath);
_currentFilePath = newPath; _currentFilePath = newPath;
}); });
ScaffoldMessenger.of(context).showSnackBar(
if (mounted) { const SnackBar(content: Text('A4請求書PDFを更新しました')),
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: '${_currentInvoice.type.label}データ_CSV'); Share.share(csvData, subject: '請求書データ_CSV');
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final dateFormatter = DateFormat('yyyy年MM月dd日'); final amountFormatter = NumberFormat("#,###");
final amountFormatter = NumberFormat("¥#,###");
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text("販売アシスト1号 ${_currentInvoice.type.label}詳細"), title: const Text("販売アシスト1号 請求書詳細"),
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出力"),
@ -155,110 +131,78 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
] ]
], ],
), ),
body: NotificationListener<ScrollStartNotification>( body: SingleChildScrollView(
onNotification: (notification) { padding: const EdgeInsets.all(16.0),
// child: Column(
_userScrolled = true; crossAxisAlignment: CrossAxisAlignment.start,
return false; children: [
}, _buildHeaderSection(),
child: SingleChildScrollView( const Divider(height: 32),
controller: _scrollController, // ScrollController const Text("明細一覧", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
padding: const EdgeInsets.all(16.0), const SizedBox(height: 8),
child: Column( _buildItemTable(amountFormatter),
crossAxisAlignment: CrossAxisAlignment.start, if (_isEditing)
children: [ Padding(
_buildHeaderSection(), padding: const EdgeInsets.only(top: 8.0),
const Divider(height: 32), child: Wrap(
const Text("明細一覧", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), spacing: 12,
const SizedBox(height: 8), runSpacing: 8,
_buildItemTable(amountFormatter), children: [
if (_isEditing) ElevatedButton.icon(
Padding( onPressed: _addItem,
padding: const EdgeInsets.only(top: 8.0), icon: const Icon(Icons.add),
child: Wrap( label: const Text("空の行を追加"),
spacing: 12, ),
runSpacing: 8, ElevatedButton.icon(
children: [ onPressed: _pickFromMaster,
ElevatedButton.icon( icon: const Icon(Icons.list_alt),
onPressed: _addItem, label: const Text("マスターから選択"),
icon: const Icon(Icons.add), style: ElevatedButton.styleFrom(
label: const Text("空の行を追加"), backgroundColor: Colors.blueGrey.shade700,
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), ),
_buildSummarySection(amountFormatter), const SizedBox(height: 24),
const SizedBox(height: 24), _buildSummarySection(amountFormatter),
_buildFooterActions(), const SizedBox(height: 24),
], _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) ...[
TextFormField( TextField(
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),
TextFormField( TextField(
controller: _notesController, controller: _notesController,
decoration: const InputDecoration(labelText: "備考", border: OutlineInputBorder()),
maxLines: 2, maxLines: 2,
onChanged: (value) => setState(() {}), // decoration: const InputDecoration(labelText: "備考", border: OutlineInputBorder()),
), ),
] else ...[ ] else ...[
Row( Text("${_currentInvoice.customer.formalName} ${_currentInvoice.customer.title}",
mainAxisAlignment: MainAxisAlignment.spaceBetween, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
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("発行日: ${DateFormat('yyyy年MM月dd日').format(_currentInvoice.date)}"), Text("請求番号: ${_currentInvoice.invoiceNumber}"),
// InvoiceDetailPageでは unitPrice totalAmount PDF生成時に計算していたため Text("発行日: ${dateFormatter.format(_currentInvoice.date)}"),
// `_isEditing` TextField `widget.invoice.unitPrice` if (_currentInvoice.notes?.isNotEmpty ?? false) ...[
// `_currentInvoice` `unitPrice` `_amountController` 使 const SizedBox(height: 8),
// `_currentInvoice.unitPrice` ReadOnly `_amountController` 使 Text("備考: ${_currentInvoice.notes}", style: const TextStyle(color: Colors.black87)),
]
], ],
], ],
); );
@ -268,58 +212,52 @@ 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),
}, },
verticalAlignment: TableCellVerticalAlignment.middle, defaultVerticalAlignment: 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;
return TableRow(children: [ if (_isEditing) {
if (_isEditing) return TableRow(children: [
_EditableCell( _EditableCell(
initialValue: item.description, initialValue: item.description,
onChanged: (val) => setState(() => item.description = val), onChanged: (val) => 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),
) ),
else _TableCell(formatter.format(item.subtotal)),
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)),
if (_isEditing) const SizedBox(),
IconButton(icon: const Icon(Icons.delete_outline, size: 20, color: Colors.redAccent), onPressed: () => _removeItem(idx)), ]);
if (!_isEditing) const SizedBox.shrink(), // SizedBox }
]); }),
}).toList(),
], ],
); );
} }
@ -327,27 +265,22 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
Widget _buildSummarySection(NumberFormat formatter) { Widget _buildSummarySection(NumberFormat formatter) {
return Align( return Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: SizedBox( child: Container(
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) { return _items.fold(0, (sum, item) => sum + (item.quantity * item.unitPrice));
//
int price = item.isDiscount ? -item.unitPrice : item.unitPrice;
return sum + (item.quantity * price);
});
} }
Widget _buildFooterActions() { Widget _buildFooterActions() {
@ -367,7 +300,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),
), ),
), ),
@ -375,31 +308,8 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
); );
} }
Future<void> _openPdf() async { Future<void> _openPdf() async => await OpenFilex.open(_currentFilePath!);
if (_currentFilePath != null) { Future<void> _sharePdf() async => await Share.shareXFiles([XFile(_currentFilePath!)], text: '請求書送付');
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 {
@ -417,6 +327,7 @@ 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),
@ -426,8 +337,6 @@ 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), //
), ),
); );
} }
@ -448,37 +357,3 @@ class _SummaryRow extends StatelessWidget {
), ),
); );
} }
```
###
1. ****:
* 調 ****
* ****
*
2. ****:
* ****
*
3. **PDF生成への反映**:
* `pdf_generator.dart` PDF生成ロジックで調
4. **UIの微調整**:
* ¥
* `TextFormField` 使Flutterの標準的な挙動では少し難しいのですが
*
### 使
* ****:
1.
* 調 ****
3.
* ****:
1.
* OK

View file

@ -1,186 +0,0 @@
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,14 +1,12 @@
// 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;
@ -24,37 +22,25 @@ 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 _invoiceRepository = InvoiceRepository(); final _repository = InvoiceRepository();
final _masterRepository = MasterRepository();
DocumentType _selectedType = DocumentType.invoice; //
String _status = "取引先を選択してPDFを生成してください"; 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();
_loadInitialData(); _selectedCustomer = Customer(
} id: const Uuid().v4(),
displayName: "佐々木製作所",
formalName: "株式会社 佐々木製作所",
);
_customerBuffer.add(_selectedCustomer!);
_clientController.text = _selectedCustomer!.formalName;
/// // PDFを掃除する
Future<void> _loadInitialData() async { _repository.cleanupOrphanedPdfs().then((count) {
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.');
} }
@ -68,7 +54,6 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
super.dispose(); super.dispose();
} }
///
Future<void> _openCustomerPicker() async { Future<void> _openCustomerPicker() async {
setState(() => _status = "顧客マスターを開いています..."); setState(() => _status = "顧客マスターを開いています...");
@ -80,12 +65,10 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
heightFactor: 0.9, heightFactor: 0.9,
child: CustomerPickerModal( child: CustomerPickerModal(
existingCustomers: _customerBuffer, existingCustomers: _customerBuffer,
onCustomerSelected: (customer) async { onCustomerSelected: (customer) {
setState(() { setState(() {
int index = _customerBuffer.indexWhere((c) => c.id == customer.id); bool exists = _customerBuffer.any((c) => c.id == customer.id);
if (index != -1) { if (!exists) {
_customerBuffer[index] = customer;
} else {
_customerBuffer.add(customer); _customerBuffer.add(customer);
} }
@ -93,26 +76,13 @@ 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 = "取引先を選択してください");
@ -123,7 +93,7 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
final initialItems = [ final initialItems = [
InvoiceItem( InvoiceItem(
description: "${_selectedType.label}", description: "ご請求",
quantity: 1, quantity: 1,
unitPrice: unitPrice, unitPrice: unitPrice,
) )
@ -133,17 +103,19 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
customer: _selectedCustomer!, customer: _selectedCustomer!,
date: DateTime.now(), date: DateTime.now(),
items: initialItems, items: initialItems,
type: _selectedType,
); );
setState(() => _status = "${_selectedType.label}を生成中..."); setState(() => _status = "A4請求書を生成中...");
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 = "${_selectedType.label}を生成しDBに登録しました。"); setState(() => _status = "PDFを生成しDBに登録しました。");
} else { } else {
setState(() => _status = "PDFの生成に失敗しました"); setState(() => _status = "PDFの生成に失敗しました");
} }
@ -151,104 +123,76 @@ 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( child: Column(children: [
crossAxisAlignment: CrossAxisAlignment.start, const Text(
children: [ "ステップ1: 宛先と基本金額の設定",
const Text( style: TextStyle(fontWeight: FontWeight.bold, color: Colors.blueGrey),
"帳票の種類を選択", ),
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.blueGrey), const SizedBox(height: 16),
), Row(children: [
const SizedBox(height: 8), Expanded(
Wrap( child: TextField(
spacing: 8.0, controller: _clientController,
children: DocumentType.values.map((type) { readOnly: true,
return ChoiceChip( onTap: _openCustomerPicker,
label: Text(type.label), decoration: const InputDecoration(
selected: _selectedType == type, labelText: "取引先名 (タップして選択)",
onSelected: (selected) { hintText: "電話帳から取り込むか、マスターから選択",
if (selected) { prefixIcon: Icon(Icons.business),
setState(() => _selectedType = type); border: OutlineInputBorder(),
}
},
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(height: 24), const SizedBox(width: 8),
ElevatedButton.icon( IconButton(
onPressed: _handleInitialGenerate, icon: const Icon(Icons.person_add_alt_1, color: Colors.indigo, size: 40),
icon: const Icon(Icons.description), onPressed: _openCustomerPicker,
label: Text("${_selectedType.label}を作成して詳細編集へ"), tooltip: "顧客を選択・登録",
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( const SizedBox(height: 16),
width: double.infinity, TextField(
padding: const EdgeInsets.all(12), controller: _amountController,
decoration: BoxDecoration( keyboardType: TextInputType.number,
color: Colors.grey[100], decoration: const InputDecoration(
borderRadius: BorderRadius.circular(8), labelText: "基本金額 (税抜)",
border: Border.all(color: Colors.grey.shade300), hintText: "明細の1行目として登録されます",
), prefixIcon: Icon(Icons.currency_yen),
child: Text( border: OutlineInputBorder(),
_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

@ -0,0 +1,99 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:open_filex/open_filex.dart';
class PdfListScreen extends StatefulWidget {
const PdfListScreen({super.key});
@override
State<PdfListScreen> createState() => _PdfListScreenState();
}
class _PdfListScreenState extends State<PdfListScreen> {
List<FileSystemEntity> _pdfFiles = [];
bool _loading = true;
@override
void initState() {
super.initState();
_fetchPdfFiles();
}
Future<void> _fetchPdfFiles() async {
// Request storage permission
final status = await Permission.storage.request();
if (!status.isGranted) {
setState(() {
_loading = false;
});
return;
}
// Prefer to look in the Downloads folder if available
Directory? downloadsDir;
if (Platform.isAndroid) {
downloadsDir = await getExternalStorageDirectory();
// The Downloads folder may be a subdirectory; adjust as needed
if (downloadsDir != null) {
downloadsDir = Directory('${downloadsDir.path}/Download');
}
} else if (Platform.isIOS) {
downloadsDir = await getApplicationDocumentsDirectory();
} else {
downloadsDir = await getApplicationDocumentsDirectory();
}
if (downloadsDir == null || !await downloadsDir.exists()) {
setState(() {
_loading = false;
});
return;
}
final files = downloadsDir
.listSync()
.where((f) => f.path.toLowerCase().endsWith('.pdf'))
.toList();
setState(() {
_pdfFiles = files;
_loading = false;
});
}
void _openFile(FileSystemEntity file) async {
final result = await OpenFilex.open(file.path);
if (result.type != ResultType.success) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not open ${file.path}')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('PDF ファイル一覧'),
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _pdfFiles.isEmpty
? const Center(child: Text('PDF ファイルが見つかりません。'))
: ListView.builder(
itemCount: _pdfFiles.length,
itemBuilder: (context, index) {
final file = _pdfFiles[index];
final fileName = file.path.split(Platform.pathSeparator).last;
return ListTile(
leading: const Icon(Icons.picture_as_pdf),
title: Text(fileName),
onTap: () => _openFile(file),
);
},
),
);
}
}

View file

@ -2,7 +2,6 @@ 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 {
@ -18,31 +17,19 @@ 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();
_loadProducts(); // ProductMasterの初期データを使用
_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) {
@ -96,34 +83,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: () async { onPressed: () {
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;
Product updatedProduct; setState(() {
if (existingProduct != null) { if (existingProduct != null) {
updatedProduct = existingProduct.copyWith( //
name: name, final index = _masterProducts.indexWhere((p) => p.id == existingProduct.id);
defaultUnitPrice: price, if (index != -1) {
category: categoryController.text.trim(), _masterProducts[index] = existingProduct.copyWith(
); name: name,
} else { defaultUnitPrice: price,
updatedProduct = Product( category: categoryController.text.trim(),
id: idController.text.isEmpty ? const Uuid().v4().substring(0, 8) : idController.text, );
name: name, }
defaultUnitPrice: price, } else {
category: categoryController.text.trim(), //
); _masterProducts.add(Product(
} id: idController.text.isEmpty ? const Uuid().v4().substring(0, 8) : idController.text,
name: name,
// defaultUnitPrice: price,
await _masterRepository.upsertProduct(updatedProduct); category: categoryController.text.trim(),
));
if (mounted) { }
Navigator.pop(context); _filterProducts();
_loadProducts(); // });
} Navigator.pop(context);
}, },
child: const Text("保存"), child: const Text("保存"),
), ),
@ -142,15 +129,12 @@ 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: () async { onPressed: () {
setState(() { setState(() {
_masterProducts.removeWhere((p) => p.id == product.id); _masterProducts.removeWhere((p) => p.id == product.id);
});
await _masterRepository.saveProducts(_masterProducts);
if (mounted) {
Navigator.pop(context);
_filterProducts(); _filterProducts();
} });
Navigator.pop(context);
}, },
child: const Text("削除する", style: TextStyle(color: Colors.red)), child: const Text("削除する", style: TextStyle(color: Colors.red)),
), ),
@ -161,10 +145,7 @@ 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,17 +38,10 @@ 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) {
final oldInvoice = all[index]; // PDFの掃除
final oldPath = oldInvoice.filePath; final oldPath = all[index].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 {
@ -87,11 +80,8 @@ 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!)
@ -105,7 +95,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のどの請求データ // DBに登録されていないPDFは削除
if (!registeredPaths.contains(entity.path)) { if (!registeredPaths.contains(entity.path)) {
await entity.delete(); await entity.delete();
deletedCount++; deletedCount++;

View file

@ -1,150 +0,0 @@
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,4 +1,3 @@
// 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;
@ -8,13 +7,9 @@ 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();
@ -24,16 +19,8 @@ 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(
@ -47,11 +34,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(docTitle, style: pw.TextStyle(fontSize: 28, fontWeight: pw.FontWeight.bold)), pw.Text("請求書", 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)}"),
], ],
), ),
@ -68,17 +55,15 @@ Future<String?> generateInvoicePdf(Invoice invoice) async {
child: pw.Column( child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start, crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [ children: [
pw.Text("${invoice.customer.formalName}$honorific", pw.Container(
style: const pw.TextStyle(fontSize: 18)), decoration: const pw.BoxDecoration(
if (invoice.customer.department != null && invoice.customer.department!.isNotEmpty) border: pw.Border(bottom: pw.BorderSide(width: 1)),
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(invoice.type == DocumentType.estimate pw.Text("下記の通り、ご請求申し上げます。"),
? "下記の通り、御見積申し上げます。"
: "下記の通り、ご請求申し上げます。"),
], ],
), ),
), ),
@ -86,11 +71,10 @@ Future<String?> generateInvoicePdf(Invoice invoice) async {
child: pw.Column( child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.end, crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [ children: [
pw.Text(company.formalName, style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)), pw.Text("自社名が入ります", style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)),
if (company.zipCode != null && company.zipCode!.isNotEmpty) pw.Text(company.zipCode!), pw.Text("〒000-0000"),
if (company.address != null && company.address!.isNotEmpty) pw.Text(company.address!), pw.Text("住所がここに入ります"),
if (company.tel != null && company.tel!.isNotEmpty) pw.Text(company.tel!), pw.Text("TEL: 00-0000-0000"),
if (company.registrationNumber != null && company.registrationNumber!.isNotEmpty) pw.Text(company.registrationNumber! ),
], ],
), ),
), ),
@ -105,8 +89,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("${docTitle}金額合計 (税込)", style: const pw.TextStyle(fontSize: 16)), pw.Text("ご請求金額合計 (税込)", 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)),
], ],
), ),
@ -151,7 +135,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),
], ],
), ),
), ),
@ -166,7 +150,8 @@ 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(
@ -180,17 +165,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.type.name}_${dateFileStr}_${invoice.customer.formalName}_$hash.pdf"; String fileName = "Invoice_${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");
@ -198,88 +183,6 @@ 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,13 +6,9 @@
#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,7 +3,6 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
printing
url_launcher_linux url_launcher_linux
) )

View file

@ -5,12 +5,10 @@
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

@ -172,26 +172,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: hooks name: hooks
sha256: "5d309c86e7ce34cd8e37aa71cb30cb652d3829b900ab145e4d9da564b31d59f7" sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" version: "1.0.1"
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:
@ -292,10 +276,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: objective_c name: objective_c
sha256: "983c7fa1501f6dcc0cb7af4e42072e9993cb28d73604d25ebf4dab08165d997e" sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.2.5" version: "9.3.0"
open_filex: open_filex:
dependency: "direct main" dependency: "direct main"
description: description:
@ -376,14 +360,6 @@ 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:
@ -464,14 +440,6 @@ 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:
@ -513,10 +481,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: source_span name: source_span
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.10.1" version: "1.10.2"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:
@ -630,7 +598,7 @@ packages:
source: hosted source: hosted
version: "3.1.5" version: "3.1.5"
uuid: uuid:
dependency: "direct main" dependency: transitive
description: description:
name: uuid name: uuid
sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8

View file

@ -43,8 +43,6 @@ 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,15 +7,12 @@
#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,7 +4,6 @@
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
) )