2953 lines
105 KiB
Text
2953 lines
105 KiB
Text
|
||
# ==========================================
|
||
# 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 同期を見据えて、外部ID(odooId)を保持できるように設計
|
||
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 同期を見据えて、外部ID(odooId)を保持できるように設計
|
||
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();
|
||
}
|
||
}
|