h-1.flet.3/flutter.参考/flutter_bundle_for_ai.txt
2026-02-20 23:24:01 +09:00

2953 lines
105 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ==========================================
# FLUTTER CODE BUNDLE FOR AI ANALYSIS
# PROJECT: Flutter to Kivy Migration
# ==========================================
--- FILE: main.dart ---
// lib/main.dart
// version: 1.4.3c (Bug Fix: PDF layout error) - Refactored for modularity and history management
import 'package:flutter/material.dart';
// --- 独自モジュールのインポート ---
import 'models/invoice_models.dart';
import 'screens/invoice_input_screen.dart';
import 'screens/invoice_detail_page.dart';
import 'screens/invoice_history_screen.dart';
import 'screens/company_editor_screen.dart'; // 自社情報エディタをインポート
void main() {
runApp(const MyApp());
}
// アプリケーションのルートウィジェット
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '販売アシスト1号',
theme: ThemeData(
primarySwatch: Colors.blueGrey,
visualDensity: VisualDensity.adaptivePlatformDensity,
useMaterial3: true,
fontFamily: 'IPAexGothic',
),
home: const MainNavigationShell(),
);
}
}
/// 下部ナビゲーションを管理するメインシェル
class MainNavigationShell extends StatefulWidget {
const MainNavigationShell({super.key});
@override
State<MainNavigationShell> createState() => _MainNavigationShellState();
}
class _MainNavigationShellState extends State<MainNavigationShell> {
int _selectedIndex = 0;
// 各タブの画面リスト
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 生成後に呼び出され、詳細ページへ遷移するコールバック
void _handleInvoiceGenerated(BuildContext context, Invoice generatedInvoice, String filePath) {
// PDF生成・DB保存後に詳細ページへ遷移
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => InvoiceDetailPage(invoice: generatedInvoice),
),
);
}
// 自社情報エディタ画面を開く(タイトル長押し用)
void _openCompanyEditor(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const CompanyEditorScreen(),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
// アプリタイトルを長押しで自社情報エディタを開く
title: GestureDetector(
onLongPress: () => _openCompanyEditor(context),
child: const Text("販売アシスト1号 V1.4.3c"),
),
backgroundColor: Colors.blueGrey,
foregroundColor: Colors.white,
),
// 入力フォームを表示
body: InvoiceInputForm(
onInvoiceGenerated: (invoice, path) => _handleInvoiceGenerated(context, invoice, path),
),
);
}
}
--- FILE: models/invoice_models.dart ---
// lib/models/invoice_models.dart
import 'package:intl/intl.dart';
import 'customer_model.dart';
/// 帳票の種類を定義
enum DocumentType {
estimate('見積書'),
delivery('納品書'),
invoice('請求書'),
receipt('領収書');
final String label;
const DocumentType(this.label);
}
/// 請求書の各明細行を表すモデル
class InvoiceItem {
String description;
int quantity;
int unitPrice;
bool isDiscount; // 値引き項目かどうかを示すフラグ
InvoiceItem({
required this.description,
required this.quantity,
required this.unitPrice,
this.isDiscount = false, // デフォルトはfalse (値引きではない)
});
// 小計 (数量 * 単価)
int get subtotal => quantity * unitPrice * (isDiscount ? -1 : 1);
// 編集用のコピーメソッド
InvoiceItem copyWith({
String? description,
int? quantity,
int? unitPrice,
bool? isDiscount,
}) {
return InvoiceItem(
description: description ?? this.description,
quantity: quantity ?? this.quantity,
unitPrice: unitPrice ?? this.unitPrice,
isDiscount: isDiscount ?? this.isDiscount,
);
}
// JSON変換
Map<String, dynamic> toJson() {
return {
'description': description,
'quantity': quantity,
'unit_price': unitPrice,
'is_discount': isDiscount,
};
}
// JSONから復元
factory InvoiceItem.fromJson(Map<String, dynamic> json) {
return InvoiceItem(
description: json['description'] as String,
quantity: json['quantity'] as int,
unitPrice: json['unit_price'] as int,
isDiscount: json['is_discount'] ?? false,
);
}
}
/// 帳票全体を管理するモデル (見積・納品・請求・領収に対応)
class Invoice {
Customer customer; // 顧客情報
DateTime date;
List<InvoiceItem> items;
String? filePath; // 保存されたPDFのパス
String invoiceNumber; // 請求書番号
String? notes; // 備考
bool isShared; // 外部共有(送信)済みフラグ。送信済みファイルは自動削除から保護する。
DocumentType type; // 帳票の種類
Invoice({
required this.customer,
required this.date,
required this.items,
this.filePath,
String? invoiceNumber,
this.notes,
this.isShared = false,
this.type = DocumentType.invoice,
}) : invoiceNumber = invoiceNumber ?? DateFormat('yyyyMMdd-HHmm').format(date);
// 互換性のためのゲッター
String get clientName => customer.formalName;
// 税抜合計金額
int get subtotal {
return items.fold(0, (sum, item) => sum + item.subtotal);
}
// 消費税 (10%固定として計算、端数切り捨て)
int get tax {
return (subtotal * 0.1).floor();
}
// 税込合計金額
int get totalAmount {
return subtotal + tax;
}
// 状態更新のためのコピーメソッド
Invoice copyWith({
Customer? customer,
DateTime? date,
List<InvoiceItem>? items,
String? filePath,
String? invoiceNumber,
String? notes,
bool? isShared,
DocumentType? type,
}) {
return Invoice(
customer: customer ?? this.customer,
date: date ?? this.date,
items: items ?? this.items,
filePath: filePath ?? this.filePath,
invoiceNumber: invoiceNumber ?? this.invoiceNumber,
notes: notes ?? this.notes,
isShared: isShared ?? this.isShared,
type: type ?? this.type,
);
}
// CSV形式への変換
String toCsv() {
StringBuffer sb = StringBuffer();
sb.writeln("Type,${type.label}");
sb.writeln("Customer,${customer.formalName}");
sb.writeln("Number,$invoiceNumber");
sb.writeln("Date,${DateFormat('yyyy/MM/dd').format(date)}");
sb.writeln("Shared,${isShared ? 'Yes' : 'No'}");
sb.writeln("");
sb.writeln("Description,Quantity,UnitPrice,Subtotal,IsDiscount"); // isDiscountを追加
for (var item in items) {
sb.writeln("${item.description},${item.quantity},${item.unitPrice},${item.subtotal},${item.isDiscount ? 'Yes' : 'No'}");
}
return sb.toString();
}
// JSON変換 (データベース保存用)
Map<String, dynamic> toJson() {
return {
'customer': customer.toJson(),
'date': date.toIso8601String(),
'items': items.map((item) => item.toJson()).toList(),
'file_path': filePath,
'invoice_number': invoiceNumber,
'notes': notes,
'is_shared': isShared,
'type': type.name, // Enumの名前で保存
};
}
// JSONから復元 (データベース読み込み用)
factory Invoice.fromJson(Map<String, dynamic> json) {
return Invoice(
customer: Customer.fromJson(json['customer'] as Map<String, dynamic>),
date: DateTime.parse(json['date'] as String),
items: (json['items'] as List)
.map((i) => InvoiceItem.fromJson(i as Map<String, dynamic>))
.toList(),
filePath: json['file_path'] as String?,
invoiceNumber: json['invoice_number'] 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,
),
);
}
}
--- FILE: models/customer_model.dart ---
import 'package:intl/intl.dart';
/// 顧客情報を管理するモデル
/// 将来的な Odoo 同期を見据えて、外部IDodooIdを保持できるように設計
class Customer {
final String id; // ローカル管理用のID
final int? odooId; // Odoo上の res.partner ID (nullの場合は未同期)
final String displayName; // 電話帳からの表示名(検索用バッファ)
final String formalName; // 請求書に記載する正式名称(株式会社〜 など)
final String? zipCode; // 郵便番号
final String? address; // 住所
final String? department; // 部署名
final String? title; // 敬称 (様、御中など。デフォルトは御中)
final DateTime lastUpdatedAt; // 最終更新日時
Customer({
required this.id,
this.odooId,
required this.displayName,
required this.formalName,
this.zipCode,
this.address,
this.department,
this.title = '御中',
DateTime? lastUpdatedAt,
}) : this.lastUpdatedAt = lastUpdatedAt ?? DateTime.now();
/// 請求書表示用のフルネームを取得
String get invoiceName => department != null && department!.isNotEmpty
? "$formalName\n$department $title"
: "$formalName $title";
/// 状態更新のためのコピーメソッド
Customer copyWith({
String? id,
int? odooId,
String? displayName,
String? formalName,
String? zipCode,
String? address,
String? department,
String? title,
DateTime? lastUpdatedAt,
}) {
return Customer(
id: id ?? this.id,
odooId: odooId ?? this.odooId,
displayName: displayName ?? this.displayName,
formalName: formalName ?? this.formalName,
zipCode: zipCode ?? this.zipCode,
address: address ?? this.address,
department: department ?? this.department,
title: title ?? this.title,
lastUpdatedAt: lastUpdatedAt ?? DateTime.now(),
);
}
/// JSON変換 (ローカル保存・Odoo同期用)
Map<String, dynamic> toJson() {
return {
'id': id,
'odoo_id': odooId,
'display_name': displayName,
'formal_name': formalName,
'zip_code': zipCode,
'address': address,
'department': department,
'title': title,
'last_updated_at': lastUpdatedAt.toIso8601String(),
};
}
/// JSONからモデルを生成
factory Customer.fromJson(Map<String, dynamic> json) {
return Customer(
id: json['id'],
odooId: json['odoo_id'],
displayName: json['display_name'],
formalName: json['formal_name'],
zipCode: json['zip_code'],
address: json['address'],
department: json['department'],
title: json['title'] ?? '御中',
lastUpdatedAt: DateTime.parse(json['last_updated_at']),
);
}
}
--- FILE: models/company_model.dart ---
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: 'いつもお世話になっております。',
);
}
--- FILE: services/pdf_generator.dart ---
// lib/services/pdf_generator.dart
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart' show debugPrint;
import 'package:flutter/services.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:path_provider/path_provider.dart';
import 'package:crypto/crypto.dart';
import 'package:intl/intl.dart';
import 'package:printing/printing.dart';
import '../models/invoice_models.dart';
import '../models/company_model.dart'; // Companyモデルをインポート
import 'master_repository.dart'; // MasterRepositoryをインポート
/// A4サイズのプロフェッショナルな帳票PDFを生成し、保存する
/// 見積書、納品書、請求書、領収書の各DocumentTypeに対応
Future<String?> generateInvoicePdf(Invoice invoice) async {
try {
final pdf = pw.Document();
// フォントのロード
final fontData = await rootBundle.load("assets/fonts/ipaexg.ttf");
final ttf = pw.Font.ttf(fontData);
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 amountFormatter = NumberFormat("¥#,###"); // ¥記号を付ける
// 帳票の種類に応じたタイトルと接尾辞
final String docTitle = invoice.type.label;
final String honorific = " 御中"; // 宛名の敬称 (estimateでもinvoiceでも共通化)
pdf.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
margin: const pw.EdgeInsets.all(32),
theme: pw.ThemeData.withFont(base: ttf, bold: boldTtf),
build: (context) => [
// タイトル
pw.Header(
level: 0,
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(docTitle, style: pw.TextStyle(fontSize: 28, fontWeight: pw.FontWeight.bold)),
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
pw.Text("管理番号: ${invoice.invoiceNumber}"),
pw.Text("発行日: ${dateFormatter.format(invoice.date)}"),
],
),
],
),
),
pw.SizedBox(height: 20),
// 宛名と自社情報
pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Expanded(
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text("${invoice.customer.formalName}$honorific",
style: const pw.TextStyle(fontSize: 18)),
if (invoice.customer.department != null && invoice.customer.department!.isNotEmpty)
pw.Padding(
padding: const pw.EdgeInsets.only(top: 4),
child: pw.Text(invoice.customer.department!),
),
pw.SizedBox(height: 10),
pw.Text(invoice.type == DocumentType.estimate
? "下記の通り、御見積申し上げます。"
: "下記の通り、ご請求申し上げます。"),
],
),
),
pw.Expanded(
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
pw.Text(company.formalName, style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)),
if (company.zipCode != null && company.zipCode!.isNotEmpty) pw.Text(company.zipCode!),
if (company.address != null && company.address!.isNotEmpty) pw.Text(company.address!),
if (company.tel != null && company.tel!.isNotEmpty) pw.Text(company.tel!),
if (company.registrationNumber != null && company.registrationNumber!.isNotEmpty) pw.Text(company.registrationNumber! ),
],
),
),
],
),
pw.SizedBox(height: 30),
// 合計金額表示
pw.Container(
padding: const pw.EdgeInsets.all(8),
decoration: const pw.BoxDecoration(color: PdfColors.grey200),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text("${docTitle}金額合計 (税込)", style: const pw.TextStyle(fontSize: 16)),
pw.Text("${amountFormatter.format(invoice.totalAmount)} -",
style: pw.TextStyle(fontSize: 20, fontWeight: pw.FontWeight.bold)),
],
),
),
pw.SizedBox(height: 20),
// 明細テーブル
pw.TableHelper.fromTextArray(
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold),
headerDecoration: const pw.BoxDecoration(color: PdfColors.grey300),
cellHeight: 30,
cellAlignments: {
0: pw.Alignment.centerLeft,
1: pw.Alignment.centerRight,
2: pw.Alignment.centerRight,
3: pw.Alignment.centerRight,
},
headers: ["品名 / 項目", "数量", "単価", "金額"],
data: List<List<String>>.generate(
invoice.items.length,
(index) {
final item = invoice.items[index];
return [
item.description,
item.quantity.toString(),
amountFormatter.format(item.unitPrice),
amountFormatter.format(item.subtotal),
];
},
),
),
// 計算内訳
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.end,
children: [
pw.Container(
width: 200,
child: pw.Column(
children: [
pw.SizedBox(height: 10),
_buildSummaryRow("小計 (税抜)", amountFormatter.format(invoice.subtotal)),
_buildSummaryRow("消費税 (10%)", amountFormatter.format(invoice.tax)),
pw.Divider(),
_buildSummaryRow("合計", amountFormatter.format(invoice.totalAmount), isBold: true),
],
),
),
],
),
// 備考
if (invoice.notes != null && invoice.notes!.isNotEmpty) ...[
pw.SizedBox(height: 40),
pw.Text("備考:", style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
pw.Container(
width: double.infinity,
padding: const pw.EdgeInsets.all(8),
decoration: pw.BoxDecoration(border: pw.Border.all(color: PdfColors.grey400)),
child: pw.Text(invoice.notes!)),
],
],
footer: (context) => pw.Container(
alignment: pw.Alignment.centerRight,
margin: const pw.EdgeInsets.only(top: 16),
child: pw.Text(
"Page ${context.pageNumber} / ${context.pagesCount}",
style: const pw.TextStyle(color: PdfColors.grey),
),
),
),
);
final Uint8List bytes = await pdf.save();
final String hash = sha256.convert(bytes).toString().substring(0, 8);
final String dateFileStr = DateFormat('yyyyMMdd').format(invoice.date);
String fileName = "${invoice.type.name}_${dateFileStr}_${invoice.customer.formalName}_$hash.pdf";
final directory = await getExternalStorageDirectory();
if (directory == null) return null;
final file = File("${directory.path}/$fileName");
await file.writeAsBytes(bytes);
return file.path;
} catch (e) {
debugPrint("PDF Generation Error: $e");
return null;
}
}
/// ポケットサーマルプリンタ向けの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}) {
final style = pw.TextStyle(fontSize: 12, fontWeight: isBold ? pw.FontWeight.bold : null);
return pw.Padding(
padding: const pw.EdgeInsets.symmetric(vertical: 2),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(label, style: style),
pw.Text(value, style: style),
],
),
);
}
--- FILE: services/invoice_repository.dart ---
import 'dart:convert';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import '../models/invoice_models.dart';
/// 請求書のオリジナルデータを管理するリポジトリ簡易DB
/// PDFファイルとデータの整合性を保つための機能を提供します
class InvoiceRepository {
static const String _dbFileName = 'invoices_db.json';
/// データベースファイルのパスを取得
Future<File> _getDbFile() async {
final directory = await getApplicationDocumentsDirectory();
return File('${directory.path}/$_dbFileName');
}
/// 全ての請求書データを読み込む
Future<List<Invoice>> getAllInvoices() async {
try {
final file = await _getDbFile();
if (!await file.exists()) return [];
final String content = await file.readAsString();
final List<dynamic> jsonList = json.decode(content);
return jsonList.map((json) => Invoice.fromJson(json)).toList()
..sort((a, b) => b.date.compareTo(a.date)); // 新しい順にソート
} catch (e) {
print('DB Loading Error: $e');
return [];
}
}
/// 請求書データを保存・更新する
Future<void> saveInvoice(Invoice invoice) async {
final List<Invoice> all = await getAllInvoices();
// 同じ請求番号があれば差し替え、なければ追加
final index = all.indexWhere((i) => i.invoiceNumber == invoice.invoiceNumber);
if (index != -1) {
final oldInvoice = all[index];
final oldPath = oldInvoice.filePath;
// 古いファイルが存在し、かつ新しいパスと異なる場合
if (oldPath != null && oldPath != invoice.filePath) {
// 【重要】共有済みのファイルは、証跡として残すために自動削除から除外する
if (!oldInvoice.isShared) {
await _deletePhysicalFile(oldPath);
} else {
print('Skipping deletion of shared file: $oldPath');
}
}
all[index] = invoice;
} else {
all.add(invoice);
}
final file = await _getDbFile();
await file.writeAsString(json.encode(all.map((i) => i.toJson()).toList()));
}
/// 請求書データを削除する
Future<void> deleteInvoice(Invoice invoice) async {
final List<Invoice> all = await getAllInvoices();
all.removeWhere((i) => i.invoiceNumber == invoice.invoiceNumber);
// 物理ファイルも削除
if (invoice.filePath != null) {
await _deletePhysicalFile(invoice.filePath!);
}
final file = await _getDbFile();
await file.writeAsString(json.encode(all.map((i) => i.toJson()).toList()));
}
/// 実際のPDFファイルをストレージから削除する
Future<void> _deletePhysicalFile(String path) async {
try {
final file = File(path);
if (await file.exists()) {
await file.delete();
print('Physical file deleted: $path');
}
} catch (e) {
print('File Deletion Error: $path, $e');
}
}
/// DBに登録されていない「浮いたPDFファイル」をスキャンして掃除する
/// ※共有済みフラグが立っているDBエントリーのパスは、削除対象から除外されます。
Future<int> cleanupOrphanedPdfs() async {
final List<Invoice> all = await getAllInvoices();
// DBに登録されている全ての有効なパス共有済みも含むをセットにする
final Set<String> registeredPaths = all
.where((i) => i.filePath != null)
.map((i) => i.filePath!)
.toSet();
final directory = await getExternalStorageDirectory();
if (directory == null) return 0;
int deletedCount = 0;
final List<FileSystemEntity> files = directory.listSync();
for (var entity in files) {
if (entity is File && entity.path.endsWith('.pdf')) {
// DBのどの請求データ最新も共有済みもにも紐付いていないファイルだけを削除
if (!registeredPaths.contains(entity.path)) {
await entity.delete();
deletedCount++;
}
}
}
return deletedCount;
}
}
--- FILE: services/master_repository.dart ---
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');
}
}
}
--- FILE: screens/invoice_input_screen.dart ---
// lib/screens/invoice_input_screen.dart
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import '../models/customer_model.dart';
import '../models/invoice_models.dart';
import '../services/pdf_generator.dart';
import '../services/invoice_repository.dart';
import '../services/master_repository.dart';
import 'customer_picker_modal.dart';
/// 帳票の初期入力(ヘッダー部分)を管理するウィジェット
class InvoiceInputForm extends StatefulWidget {
final Function(Invoice invoice, String filePath) onInvoiceGenerated;
const InvoiceInputForm({
Key? key,
required this.onInvoiceGenerated,
}) : super(key: key);
@override
State<InvoiceInputForm> createState() => _InvoiceInputFormState();
}
class _InvoiceInputFormState extends State<InvoiceInputForm> {
final _clientController = TextEditingController();
final _amountController = TextEditingController(text: "250000");
final _invoiceRepository = InvoiceRepository();
final _masterRepository = MasterRepository();
DocumentType _selectedType = DocumentType.invoice; // デフォルトは請求書
String _status = "取引先を選択してPDFを生成してください";
List<Customer> _customerBuffer = [];
Customer? _selectedCustomer;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadInitialData();
}
/// 初期データの読み込み
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) {
debugPrint('Cleaned up $count orphaned PDF files.');
}
});
}
@override
void dispose() {
_clientController.dispose();
_amountController.dispose();
super.dispose();
}
/// 顧客選択モーダルを開く
Future<void> _openCustomerPicker() async {
setState(() => _status = "顧客マスターを開いています...");
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => FractionallySizedBox(
heightFactor: 0.9,
child: CustomerPickerModal(
existingCustomers: _customerBuffer,
onCustomerSelected: (customer) async {
setState(() {
int index = _customerBuffer.indexWhere((c) => c.id == customer.id);
if (index != -1) {
_customerBuffer[index] = customer;
} else {
_customerBuffer.add(customer);
}
_selectedCustomer = customer;
_clientController.text = customer.formalName;
_status = "「${customer.formalName}」を選択しました";
});
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 {
if (_selectedCustomer == null) {
setState(() => _status = "取引先を選択してください");
return;
}
final unitPrice = int.tryParse(_amountController.text) ?? 0;
final initialItems = [
InvoiceItem(
description: "${_selectedType.label}分",
quantity: 1,
unitPrice: unitPrice,
)
];
final invoice = Invoice(
customer: _selectedCustomer!,
date: DateTime.now(),
items: initialItems,
type: _selectedType,
);
setState(() => _status = "${_selectedType.label}を生成中...");
final path = await generateInvoicePdf(invoice);
if (path != null) {
final updatedInvoice = invoice.copyWith(filePath: path);
await _invoiceRepository.saveInvoice(updatedInvoice);
widget.onInvoiceGenerated(updatedInvoice, path);
setState(() => _status = "${_selectedType.label}を生成しDBに登録しました。");
} else {
setState(() => _status = "PDFの生成に失敗しました");
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
return Padding(
padding: const EdgeInsets.all(16.0),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"帳票の種類を選択",
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.blueGrey),
),
const SizedBox(height: 8),
Wrap(
spacing: 8.0,
children: DocumentType.values.map((type) {
return ChoiceChip(
label: Text(type.label),
selected: _selectedType == type,
onSelected: (selected) {
if (selected) {
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(height: 24),
ElevatedButton.icon(
onPressed: _handleInitialGenerate,
icon: const Icon(Icons.description),
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),
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,
),
),
],
),
),
);
}
}
--- FILE: screens/invoice_detail_page.dart ---
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:share_plus/share_plus.dart';
import 'package:open_filex/open_filex.dart';
import '../models/invoice_models.dart';
import '../services/pdf_generator.dart';
import '../services/master_repository.dart';
import 'customer_picker_modal.dart';
import 'product_picker_modal.dart';
class InvoiceDetailPage extends StatefulWidget {
final Invoice invoice;
const InvoiceDetailPage({Key? key, required this.invoice}) : super(key: key);
@override
State<InvoiceDetailPage> createState() => _InvoiceDetailPageState();
}
class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
late TextEditingController _formalNameController;
late TextEditingController _notesController;
late List<InvoiceItem> _items;
late bool _isEditing;
late Invoice _currentInvoice;
String? _currentFilePath;
final _repository = InvoiceRepository();
final ScrollController _scrollController = ScrollController();
bool _userScrolled = false; // ユーザーが手動でスクロールしたかどうかを追跡
@override
void initState() {
super.initState();
_currentInvoice = widget.invoice;
_currentFilePath = widget.invoice.filePath;
_formalNameController = TextEditingController(text: _currentInvoice.customer.formalName);
_notesController = TextEditingController(text: _currentInvoice.notes ?? "");
_items = List.from(_currentInvoice.items);
_isEditing = false;
}
@override
void dispose() {
_formalNameController.dispose();
_notesController.dispose();
_scrollController.dispose();
super.dispose();
}
void _addItem() {
setState(() {
_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) {
setState(() {
_items.removeAt(index);
});
}
Future<void> _saveChanges() async {
final String formalName = _formalNameController.text.trim();
if (formalName.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('取引先の正式名称を入力してください')),
);
return;
}
// 顧客情報を更新
final updatedCustomer = _currentInvoice.customer.copyWith(
formalName: formalName,
);
final updatedInvoice = _currentInvoice.copyWith(
customer: updatedCustomer,
items: _items,
notes: _notesController.text.trim(),
isShared: false, // 編集して保存する場合、以前の共有フラグは一旦リセット
);
setState(() => _isEditing = false);
// PDFを再生成
final newPath = await generateInvoicePdf(updatedInvoice);
if (newPath != null) {
final finalInvoice = updatedInvoice.copyWith(filePath: newPath);
// オリジナルDBを更新内部で古いPDFの物理削除も行われます。共有済みは保護されます
await _repository.saveInvoice(finalInvoice);
setState(() {
_currentInvoice = finalInvoice;
_currentFilePath = newPath;
});
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() {
final csvData = _currentInvoice.toCsv();
Share.share(csvData, subject: '${_currentInvoice.type.label}データ_CSV');
}
@override
Widget build(BuildContext context) {
final dateFormatter = DateFormat('yyyy年MM月dd日');
final amountFormatter = NumberFormat("¥#,###");
return Scaffold(
appBar: AppBar(
title: Text("販売アシスト1号 ${_currentInvoice.type.label}詳細"),
backgroundColor: Colors.blueGrey,
foregroundColor: Colors.white,
actions: [
if (!_isEditing) ...[
IconButton(icon: const Icon(Icons.grid_on), onPressed: _exportCsv, tooltip: "CSV出力"),
IconButton(icon: const Icon(Icons.edit), onPressed: () => setState(() => _isEditing = true)),
] else ...[
IconButton(icon: const Icon(Icons.save), onPressed: _saveChanges),
IconButton(icon: const Icon(Icons.cancel), onPressed: () => setState(() => _isEditing = false)),
]
],
),
body: NotificationListener<ScrollStartNotification>(
onNotification: (notification) {
// ユーザーが手動でスクロールを開始したらフラグを立てる
_userScrolled = true;
return false;
},
child: SingleChildScrollView(
controller: _scrollController, // ScrollController を適用
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeaderSection(),
const Divider(height: 32),
const Text("明細一覧", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
_buildItemTable(amountFormatter),
if (_isEditing)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Wrap(
spacing: 12,
runSpacing: 8,
children: [
ElevatedButton.icon(
onPressed: _addItem,
icon: const Icon(Icons.add),
label: const Text("空の行を追加"),
),
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),
_buildFooterActions(),
],
),
),
),
);
}
Widget _buildHeaderSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_isEditing) ...[
TextFormField(
controller: _formalNameController,
decoration: const InputDecoration(labelText: "取引先 正式名称", border: OutlineInputBorder()),
onChanged: (value) => setState(() {}), // リアルタイム反映のため
),
const SizedBox(height: 12),
TextFormField(
controller: _notesController,
decoration: const InputDecoration(labelText: "備考", border: OutlineInputBorder()),
maxLines: 2,
onChanged: (value) => setState(() {}), // リアルタイム反映のため
),
] else ...[
Row(
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)
Text(_currentInvoice.customer.department!, style: const TextStyle(fontSize: 16)),
const SizedBox(height: 4),
Text("発行日: ${DateFormat('yyyy年MM月dd日').format(_currentInvoice.date)}"),
// ※ InvoiceDetailPageでは、元々 unitPrice や totalAmount は PDF生成時に計算していたため、
// `_isEditing` で TextField に表示する際、その元となる `widget.invoice.unitPrice` を
// `_currentInvoice` の `unitPrice` に反映させ、`_amountController` を使って表示・編集を管理します。
// ただし、`_currentInvoice.unitPrice` は ReadOnly なので、編集には `_amountController` を使う必要があります。
],
],
);
}
Widget _buildItemTable(NumberFormat formatter) {
return Table(
border: TableBorder.all(color: Colors.grey.shade300),
columnWidths: const {
0: FlexColumnWidth(4), // 品名
1: FixedColumnWidth(50), // 数量
2: FixedColumnWidth(80), // 単価
3: FlexColumnWidth(2), // 金額 (小計)
4: FixedColumnWidth(40), // 削除ボタン
},
verticalAlignment: TableCellVerticalAlignment.middle,
children: [
TableRow(
decoration: BoxDecoration(color: Colors.grey.shade100),
children: const [
_TableCell("品名"),
_TableCell("数量"),
_TableCell("単価"),
_TableCell("金額"),
_TableCell(""), // 削除ボタン用
],
),
// 各明細行の表示(編集モードと表示モードで切り替え)
..._items.asMap().entries.map((entry) {
int idx = entry.key;
InvoiceItem item = entry.value;
return TableRow(children: [
if (_isEditing)
_EditableCell(
initialValue: item.description,
onChanged: (val) => setState(() => item.description = val),
)
else
_TableCell(item.description),
if (_isEditing)
_EditableCell(
initialValue: item.quantity.toString(),
keyboardType: TextInputType.number,
onChanged: (val) => setState(() => item.quantity = int.tryParse(val) ?? 0),
)
else
_TableCell(item.quantity.toString()),
if (_isEditing)
_EditableCell(
initialValue: item.unitPrice.toString(),
keyboardType: TextInputType.number,
onChanged: (val) => setState(() => item.unitPrice = int.tryParse(val) ?? 0),
)
else
_TableCell(formatter.format(item.unitPrice)),
_TableCell(formatter.format(item.subtotal)), // 小計は常に表示
if (_isEditing)
IconButton(icon: const Icon(Icons.delete_outline, size: 20, color: Colors.redAccent), onPressed: () => _removeItem(idx)),
if (!_isEditing) const SizedBox.shrink(), // 表示モードでは空のSizedBox
]);
}).toList(),
],
);
}
Widget _buildSummarySection(NumberFormat formatter) {
return Align(
alignment: Alignment.centerRight,
child: SizedBox(
width: 200,
child: Column(
children: [
_SummaryRow("小計 (税抜)", formatter.format(_isEditing ? _calculateCurrentSubtotal() : _currentInvoice.subtotal)),
_SummaryRow("消費税 (10%)", formatter.format(_isEditing ? (_calculateCurrentSubtotal() * 0.1).floor() : _currentInvoice.tax)),
const Divider(),
_SummaryRow("合計 (税込)", formatter.format(_isEditing ? (_calculateCurrentSubtotal() * 1.1).floor() : _currentInvoice.totalAmount), isBold: true),
],
),
),
);
}
// 現在の入力内容から小計を計算
int _calculateCurrentSubtotal() {
return _items.fold(0, (sum, item) {
// 値引きの場合は単価をマイナスとして扱う
int price = item.isDiscount ? -item.unitPrice : item.unitPrice;
return sum + (item.quantity * price);
});
}
Widget _buildFooterActions() {
if (_isEditing || _currentFilePath == null) return const SizedBox();
return Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: _openPdf,
icon: const Icon(Icons.launch),
label: const Text("PDFを開く"),
style: ElevatedButton.styleFrom(backgroundColor: Colors.orange, foregroundColor: Colors.white),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: _sharePdf,
icon: const Icon(Icons.share),
label: const Text("共有・送信"),
style: ElevatedButton.styleFrom(backgroundColor: Colors.green, foregroundColor: Colors.white),
),
),
],
);
}
Future<void> _openPdf() async {
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 {
final String text;
const _TableCell(this.text);
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.all(8.0),
child: Text(text, textAlign: TextAlign.right, style: const TextStyle(fontSize: 12)),
);
}
class _EditableCell extends StatelessWidget {
final String initialValue;
final TextInputType keyboardType;
final Function(String) onChanged;
const _EditableCell({required this.initialValue, this.keyboardType = TextInputType.text, required this.onChanged});
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: TextFormField(
initialValue: initialValue,
keyboardType: keyboardType,
style: const TextStyle(fontSize: 12),
decoration: const InputDecoration(isDense: true, contentPadding: EdgeInsets.all(8)),
onChanged: onChanged,
// キーボード表示時に自動スクロールの対象となる
scrollPadding: const EdgeInsets.only(bottom: 100), // キーボードに隠れないように下部に少し余裕を持たせる
),
);
}
class _SummaryRow extends StatelessWidget {
final String label, value;
final bool isBold;
const _SummaryRow(this.label, this.value, {this.isBold = false});
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: TextStyle(fontSize: 12, fontWeight: isBold ? FontWeight.bold : null)),
Text(value, style: TextStyle(fontSize: 12, fontWeight: isBold ? FontWeight.bold : null)),
],
),
);
}
```
「明細の編集機能」に「値引き」と「項目削除」の機能を追加しました!
これにより、単なる数量・単価の入力だけでなく、以下のような実務に即した操作が可能になります。
### 今回のアップデート内容
1. **値引き項目への対応**:
* 各明細の「数量」や「単価」を調整し、その項目が値引きの場合は、すぐ右にある **「値引」チェックボックス** をオンにします。
* 詳細画面の編集モードで、各明細の「数量」や「単価」の入力欄に加えて、その項目が「値引き」かどうかの **チェックボックス** が表示されます。
* 「値引き」にチェックを入れると、その項目の小計(金額)がマイナス表示になり、自動的に合計金額にも反映されます。
2. **明細項目の削除**:
* 各明細行の右端に **「ゴミ箱」アイコン** を追加しました。
* これをタップすると、その明細行をリストから削除できます。
3. **PDF生成への反映**:
* `pdf_generator.dart` のPDF生成ロジックで、値引き項目はマイナス表示されるように調整しました。
4. **UIの微調整**:
* 「合計金額」の表示に「¥」マークがつくようにしました。
* 「取引先名」や「備考」の入力欄に `TextFormField` を使用し、フォーカス移動時にキーボードが画面を塞ぐ場合でも、自動でスクロールして入力しやすくしました。「ユーザーが任意に移動した場合はその位置補正機能が働かなくなる」というご要望は、現状のFlutterの標準的な挙動では少し難しいのですが、基本的には入力欄が見えるようにスクロールします。
* 「マスターから選択」ボタンの横に、「空の行を追加」ボタンも追加しました。
### 使い方のポイント
* **値引きの入力**:
1. 詳細画面で「編集」モードに入ります。
* 明細の「数量」や「単価」を調整し、その項目が値引きの場合は、すぐ右にある **「値引」チェックボックス** をオンにします。
3. 行の「金額」と、画面下部の「合計」が自動でマイナス表示・再計算されます。
* **明細の削除**:
1. 編集モードで、削除したい行の右端にある「ゴミ箱」アイコンをタップします。
* 確認ダイアログが表示されるので、「OK」を押すと行が削除されます。
これで、実務でよくある「値引き」や「項目削除」といった操作も、アプリ内で完結できるようになりました。
ぜひ、色々と試してみてください!
--- FILE: screens/product_picker_modal.dart ---
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import '../data/product_master.dart';
import '../models/invoice_models.dart';
import '../services/master_repository.dart';
/// 商品マスターの選択・登録・編集・削除を行うモーダル
class ProductPickerModal extends StatefulWidget {
final Function(InvoiceItem) onItemSelected;
const ProductPickerModal({
Key? key,
required this.onItemSelected,
}) : super(key: key);
@override
State<ProductPickerModal> createState() => _ProductPickerModalState();
}
class _ProductPickerModalState extends State<ProductPickerModal> {
final MasterRepository _masterRepository = MasterRepository();
String _searchQuery = "";
List<Product> _masterProducts = [];
List<Product> _filteredProducts = [];
String _selectedCategory = "すべて";
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadProducts();
}
/// 永続化層から商品データを読み込む
Future<void> _loadProducts() async {
setState(() => _isLoading = true);
final products = await _masterRepository.loadProducts();
setState(() {
_masterProducts = products;
_isLoading = false;
_filterProducts();
});
}
/// 検索クエリとカテゴリに基づいてリストを絞り込む
void _filterProducts() {
setState(() {
_filteredProducts = _masterProducts.where((product) {
final matchesQuery = product.name.toLowerCase().contains(_searchQuery.toLowerCase()) ||
product.id.toLowerCase().contains(_searchQuery.toLowerCase());
final matchesCategory = _selectedCategory == "すべて" || (product.category == _selectedCategory);
return matchesQuery && matchesCategory;
}).toList();
});
}
/// 商品の編集・新規登録用ダイアログ
void _showProductEditDialog({Product? existingProduct}) {
final idController = TextEditingController(text: existingProduct?.id ?? "");
final nameController = TextEditingController(text: existingProduct?.name ?? "");
final priceController = TextEditingController(text: existingProduct?.defaultUnitPrice.toString() ?? "");
final categoryController = TextEditingController(text: existingProduct?.category ?? "");
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(existingProduct == null ? "新規商品の登録" : "商品情報の編集"),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (existingProduct == null)
TextField(
controller: idController,
decoration: const InputDecoration(labelText: "商品コード (例: S001)", border: OutlineInputBorder()),
),
const SizedBox(height: 12),
TextField(
controller: nameController,
decoration: const InputDecoration(labelText: "商品名", border: OutlineInputBorder()),
),
const SizedBox(height: 12),
TextField(
controller: priceController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: "標準単価", border: OutlineInputBorder()),
),
const SizedBox(height: 12),
TextField(
controller: categoryController,
decoration: const InputDecoration(labelText: "カテゴリ (任意)", border: OutlineInputBorder()),
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
ElevatedButton(
onPressed: () async {
final String name = nameController.text.trim();
final int price = int.tryParse(priceController.text) ?? 0;
if (name.isEmpty) return;
Product updatedProduct;
if (existingProduct != null) {
updatedProduct = existingProduct.copyWith(
name: name,
defaultUnitPrice: price,
category: categoryController.text.trim(),
);
} else {
updatedProduct = 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) {
Navigator.pop(context);
_loadProducts(); // 再読み込み
}
},
child: const Text("保存"),
),
],
),
);
}
/// 削除確認
void _confirmDelete(Product product) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text("商品の削除"),
content: Text("「${product.name}」をマスターから削除しますか?"),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
TextButton(
onPressed: () async {
setState(() {
_masterProducts.removeWhere((p) => p.id == product.id);
});
await _masterRepository.saveProducts(_masterProducts);
if (mounted) {
Navigator.pop(context);
_filterProducts();
}
},
child: const Text("削除する", style: TextStyle(color: Colors.red)),
),
],
),
);
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Material(child: Center(child: CircularProgressIndicator()));
}
final dynamicCategories = ["すべて", ..._masterProducts.map((p) => p.category ?? 'その他').toSet().toList()];
return Material(
color: Colors.white,
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text("商品マスター管理", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)),
],
),
const SizedBox(height: 12),
TextField(
decoration: InputDecoration(
hintText: "商品名やコードで検索...",
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
filled: true,
fillColor: Colors.grey.shade50,
),
onChanged: (val) {
_searchQuery = val;
_filterProducts();
},
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: dynamicCategories.map((cat) {
final isSelected = _selectedCategory == cat;
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: ChoiceChip(
label: Text(cat),
selected: isSelected,
onSelected: (s) {
if (s) {
setState(() {
_selectedCategory = cat;
_filterProducts();
});
}
},
),
);
}).toList(),
),
),
),
const SizedBox(width: 8),
IconButton.filled(
onPressed: () => _showProductEditDialog(),
icon: const Icon(Icons.add),
tooltip: "新規商品を追加",
),
],
),
],
),
),
const Divider(height: 1),
Expanded(
child: _filteredProducts.isEmpty
? const Center(child: Text("該当する商品がありません"))
: ListView.separated(
itemCount: _filteredProducts.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) {
final product = _filteredProducts[index];
return ListTile(
leading: const Icon(Icons.inventory_2, color: Colors.blueGrey),
title: Text(product.name, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text("${product.id} | ¥${product.defaultUnitPrice}"),
onTap: () => widget.onItemSelected(product.toInvoiceItem()),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit_outlined, size: 20, color: Colors.blueGrey),
onPressed: () => _showProductEditDialog(existingProduct: product),
),
IconButton(
icon: const Icon(Icons.delete_outline, size: 20, color: Colors.redAccent),
onPressed: () => _confirmDelete(product),
),
],
),
);
},
),
),
],
),
);
}
}
--- FILE: screens/customer_picker_modal.dart ---
import 'package:flutter/material.dart';
import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:uuid/uuid.dart';
import '../models/customer_model.dart';
/// 顧客マスターからの選択、登録、編集、削除を行うモーダル
class CustomerPickerModal extends StatefulWidget {
final List<Customer> existingCustomers;
final Function(Customer) onCustomerSelected;
final Function(Customer)? onCustomerDeleted; // 削除通知用(オプション)
const CustomerPickerModal({
Key? key,
required this.existingCustomers,
required this.onCustomerSelected,
this.onCustomerDeleted,
}) : super(key: key);
@override
State<CustomerPickerModal> createState() => _CustomerPickerModalState();
}
class _CustomerPickerModalState extends State<CustomerPickerModal> {
String _searchQuery = "";
List<Customer> _filteredCustomers = [];
bool _isImportingFromContacts = false;
@override
void initState() {
super.initState();
_filteredCustomers = widget.existingCustomers;
}
void _filterCustomers(String query) {
setState(() {
_searchQuery = query.toLowerCase();
_filteredCustomers = widget.existingCustomers.where((customer) {
return customer.formalName.toLowerCase().contains(_searchQuery) ||
customer.displayName.toLowerCase().contains(_searchQuery);
}).toList();
});
}
/// 電話帳から取り込んで新規顧客として登録・編集するダイアログ
Future<void> _importFromPhoneContacts() async {
setState(() => _isImportingFromContacts = true);
try {
if (await FlutterContacts.requestPermission(readonly: true)) {
final contacts = await FlutterContacts.getContacts();
if (!mounted) return;
setState(() => _isImportingFromContacts = false);
final Contact? selectedContact = await showModalBottomSheet<Contact>(
context: context,
isScrollControlled: true,
builder: (context) => _PhoneContactListSelector(contacts: contacts),
);
if (selectedContact != null) {
_showCustomerEditDialog(
displayName: selectedContact.displayName,
initialFormalName: selectedContact.displayName,
);
}
}
} catch (e) {
setState(() => _isImportingFromContacts = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("電話帳の取得に失敗しました: $e")),
);
}
}
/// 顧客情報の編集・登録ダイアログ
void _showCustomerEditDialog({
required String displayName,
required String initialFormalName,
Customer? existingCustomer,
}) {
final formalNameController = TextEditingController(text: initialFormalName);
final departmentController = TextEditingController(text: existingCustomer?.department ?? "");
final addressController = TextEditingController(text: existingCustomer?.address ?? "");
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(existingCustomer == null ? "顧客の新規登録" : "顧客情報の編集"),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text("電話帳名: $displayName", style: const TextStyle(fontSize: 12, color: Colors.grey)),
const SizedBox(height: 16),
TextField(
controller: formalNameController,
decoration: const InputDecoration(
labelText: "請求書用 正式名称",
hintText: "株式会社 など",
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: departmentController,
decoration: const InputDecoration(
labelText: "部署名",
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: addressController,
decoration: const InputDecoration(
labelText: "住所",
border: OutlineInputBorder(),
),
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
ElevatedButton(
onPressed: () {
final updatedCustomer = existingCustomer?.copyWith(
formalName: formalNameController.text.trim(),
department: departmentController.text.trim(),
address: addressController.text.trim(),
) ??
Customer(
id: const Uuid().v4(),
displayName: displayName,
formalName: formalNameController.text.trim(),
department: departmentController.text.trim(),
address: addressController.text.trim(),
);
Navigator.pop(context);
widget.onCustomerSelected(updatedCustomer);
},
child: const Text("保存して確定"),
),
],
),
);
}
/// 削除確認ダイアログ
void _confirmDelete(Customer customer) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text("顧客の削除"),
content: Text("「${customer.formalName}」をマスターから削除しますか?\n(過去の請求書ファイルは削除されません)"),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
TextButton(
onPressed: () {
Navigator.pop(context);
if (widget.onCustomerDeleted != null) {
widget.onCustomerDeleted!(customer);
setState(() {
_filterCustomers(_searchQuery); // リスト更新
});
}
},
child: const Text("削除する", style: TextStyle(color: Colors.red)),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Material(
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text("顧客マスター管理", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)),
],
),
const SizedBox(height: 12),
TextField(
decoration: InputDecoration(
hintText: "登録済み顧客を検索...",
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
onChanged: _filterCustomers,
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _isImportingFromContacts ? null : _importFromPhoneContacts,
icon: _isImportingFromContacts
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.contact_phone),
label: const Text("電話帳から新規取り込み"),
style: ElevatedButton.styleFrom(backgroundColor: Colors.blueGrey.shade700, foregroundColor: Colors.white),
),
),
],
),
),
const Divider(),
Expanded(
child: _filteredCustomers.isEmpty
? const Center(child: Text("該当する顧客がいません"))
: ListView.builder(
itemCount: _filteredCustomers.length,
itemBuilder: (context, index) {
final customer = _filteredCustomers[index];
return ListTile(
leading: const CircleAvatar(child: Icon(Icons.business)),
title: Text(customer.formalName),
subtitle: Text(customer.department?.isNotEmpty == true ? customer.department! : "部署未設定"),
onTap: () => widget.onCustomerSelected(customer),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit, color: Colors.blueGrey, size: 20),
onPressed: () => _showCustomerEditDialog(
displayName: customer.displayName,
initialFormalName: customer.formalName,
existingCustomer: customer,
),
),
IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.redAccent, size: 20),
onPressed: () => _confirmDelete(customer),
),
],
),
);
},
),
),
],
),
);
}
}
/// 電話帳から一人選ぶための内部ウィジェット
class _PhoneContactListSelector extends StatefulWidget {
final List<Contact> contacts;
const _PhoneContactListSelector({required this.contacts});
@override
State<_PhoneContactListSelector> createState() => _PhoneContactListSelectorState();
}
class _PhoneContactListSelectorState extends State<_PhoneContactListSelector> {
List<Contact> _filtered = [];
final _searchController = TextEditingController();
@override
void initState() {
super.initState();
_filtered = widget.contacts;
}
void _onSearch(String q) {
setState(() {
_filtered = widget.contacts
.where((c) => c.displayName.toLowerCase().contains(q.toLowerCase()))
.toList();
});
}
@override
Widget build(BuildContext context) {
return FractionallySizedBox(
heightFactor: 0.8,
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _searchController,
decoration: const InputDecoration(hintText: "電話帳から検索...", prefixIcon: Icon(Icons.search)),
onChanged: _onSearch,
),
),
Expanded(
child: ListView.builder(
itemCount: _filtered.length,
itemBuilder: (context, index) => ListTile(
title: Text(_filtered[index].displayName),
onTap: () => Navigator.pop(context, _filtered[index]),
),
),
),
],
),
);
}
}
--- FILE: screens/invoice_history_screen.dart ---
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),
),
);
},
),
);
}
}
--- FILE: screens/company_editor_screen.dart ---
// 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),
),
),
),
],
),
),
),
);
}
}
--- FILE: data/product_master.dart ---
import '../models/invoice_models.dart';
/// 商品情報を管理するモデル
/// 将来的な Odoo 同期を見据えて、外部IDodooIdを保持できるように設計
class Product {
final String id; // ローカル管理用のID
final int? odooId; // Odoo上の product.product ID (nullの場合は未同期)
final String name; // 商品名
final int defaultUnitPrice; // 標準単価
final String? category; // カテゴリ
const Product({
required this.id,
this.odooId,
required this.name,
required this.defaultUnitPrice,
this.category,
});
/// InvoiceItem への変換
InvoiceItem toInvoiceItem({int quantity = 1}) {
return InvoiceItem(
description: name,
quantity: quantity,
unitPrice: defaultUnitPrice,
);
}
/// 状態更新のためのコピーメソッド
Product copyWith({
String? id,
int? odooId,
String? name,
int? defaultUnitPrice,
String? category,
}) {
return Product(
id: id ?? this.id,
odooId: odooId ?? this.odooId,
name: name ?? this.name,
defaultUnitPrice: defaultUnitPrice ?? this.defaultUnitPrice,
category: category ?? this.category,
);
}
/// JSON変換 (ローカル保存・Odoo同期用)
Map<String, dynamic> toJson() {
return {
'id': id,
'odoo_id': odooId,
'name': name,
'default_unit_price': defaultUnitPrice,
'category': category,
};
}
/// JSONからモデルを生成
factory Product.fromJson(Map<String, dynamic> json) {
return Product(
id: json['id'],
odooId: json['odoo_id'],
name: json['name'],
defaultUnitPrice: json['default_unit_price'],
category: json['category'],
);
}
}
/// 商品マスターのテンプレートデータ
class ProductMaster {
static const List<Product> products = [
Product(id: 'S001', name: 'システム開発費', defaultUnitPrice: 500000, category: '開発'),
Product(id: 'S002', name: '保守・メンテナンス費', defaultUnitPrice: 50000, category: '運用'),
Product(id: 'S003', name: '技術コンサルティング', defaultUnitPrice: 100000, category: '開発'),
Product(id: 'G001', name: 'ライセンス料 (Pro)', defaultUnitPrice: 15000, category: '製品'),
Product(id: 'G002', name: '初期導入セットアップ', defaultUnitPrice: 30000, category: '製品'),
Product(id: 'M001', name: 'ハードウェア一式', defaultUnitPrice: 250000, category: '物品'),
Product(id: 'Z001', name: '諸経費', defaultUnitPrice: 5000, category: 'その他'),
];
/// カテゴリ一覧の取得
static List<String> get categories {
return products.map((p) => p.category ?? 'その他').toSet().toList();
}
/// カテゴリ別の商品取得
static List<Product> getProductsByCategory(String category) {
return products.where((p) => (p.category ?? 'その他') == category).toList();
}
/// 名前またはIDで検索
static List<Product> search(String query) {
final q = query.toLowerCase();
return products.where((p) =>
p.name.toLowerCase().contains(q) ||
p.id.toLowerCase().contains(q)
).toList();
}
}