feat: Implement terminal ID and content hash for invoices, enhance PDF generation with QR codes, and refine slide-to-unlock UI.
This commit is contained in:
parent
035baf9078
commit
25295fd619
9 changed files with 142 additions and 38 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
// lib/main.dart
|
// lib/main.dart
|
||||||
// version: 1.4.3c (Bug Fix: PDF layout error) - Refactored for modularity
|
// version: 1.5.01 (Update: SHA256 & Management Features)
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
// --- 独自モジュールのインポート ---
|
// --- 独自モジュールのインポート ---
|
||||||
|
|
@ -56,7 +56,7 @@ class _InvoiceFlowScreenState extends State<InvoiceFlowScreen> {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text("販売アシスト1号 V1.4.3c"),
|
title: const Text("販売アシスト1号 V1.5.01"),
|
||||||
backgroundColor: Colors.blueGrey,
|
backgroundColor: Colors.blueGrey,
|
||||||
),
|
),
|
||||||
drawer: Drawer(
|
drawer: Drawer(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import 'customer_model.dart';
|
import 'dart:convert';
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'customer_model.dart';
|
||||||
|
|
||||||
class InvoiceItem {
|
class InvoiceItem {
|
||||||
final String? id;
|
final String? id;
|
||||||
|
|
@ -62,6 +64,7 @@ class Invoice {
|
||||||
final DateTime updatedAt;
|
final DateTime updatedAt;
|
||||||
final double? latitude; // 追加
|
final double? latitude; // 追加
|
||||||
final double? longitude; // 追加
|
final double? longitude; // 追加
|
||||||
|
final String terminalId; // 追加: 端末識別子
|
||||||
|
|
||||||
Invoice({
|
Invoice({
|
||||||
String? id,
|
String? id,
|
||||||
|
|
@ -78,9 +81,18 @@ class Invoice {
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
this.latitude, // 追加
|
this.latitude, // 追加
|
||||||
this.longitude, // 追加
|
this.longitude, // 追加
|
||||||
|
String? terminalId, // 追加
|
||||||
}) : id = id ?? DateTime.now().millisecondsSinceEpoch.toString(),
|
}) : id = id ?? DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
|
terminalId = terminalId ?? "T1", // デフォルト端末ID
|
||||||
updatedAt = updatedAt ?? DateTime.now();
|
updatedAt = updatedAt ?? DateTime.now();
|
||||||
|
|
||||||
|
/// 伝票内容から決定論的なハッシュを生成する (SHA256の一部)
|
||||||
|
String get contentHash {
|
||||||
|
final input = "$id|$terminalId|${date.toIso8601String()}|${customer.id}|$totalAmount|${items.map((e) => "${e.description}${e.quantity}${e.unitPrice}").join()}";
|
||||||
|
final bytes = utf8.encode(input);
|
||||||
|
return sha256.convert(bytes).toString().substring(0, 8).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
String get documentTypeName {
|
String get documentTypeName {
|
||||||
switch (documentType) {
|
switch (documentType) {
|
||||||
case DocumentType.estimation: return "見積書";
|
case DocumentType.estimation: return "見積書";
|
||||||
|
|
@ -99,7 +111,7 @@ class Invoice {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String get invoiceNumber => "$invoiceNumberPrefix-${DateFormat('yyyyMMdd').format(date)}-${id.substring(id.length > 4 ? id.length - 4 : 0)}";
|
String get invoiceNumber => "$invoiceNumberPrefix-$terminalId-${DateFormat('yyyyMMdd').format(date)}-${id.substring(id.length > 4 ? id.length - 4 : 0)}";
|
||||||
|
|
||||||
// 表示用の宛名(スナップショットがあれば優先)
|
// 表示用の宛名(スナップショットがあれば優先)
|
||||||
String get customerNameForDisplay => customerFormalNameSnapshot ?? customer.formalName;
|
String get customerNameForDisplay => customerFormalNameSnapshot ?? customer.formalName;
|
||||||
|
|
@ -124,6 +136,8 @@ class Invoice {
|
||||||
'updated_at': updatedAt.toIso8601String(),
|
'updated_at': updatedAt.toIso8601String(),
|
||||||
'latitude': latitude, // 追加
|
'latitude': latitude, // 追加
|
||||||
'longitude': longitude, // 追加
|
'longitude': longitude, // 追加
|
||||||
|
'terminal_id': terminalId, // 追加
|
||||||
|
'content_hash': contentHash, // 追加
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
}
|
}
|
||||||
|
|
||||||
int get _subTotal => _items.fold(0, (sum, item) => sum + (item.unitPrice * item.quantity));
|
int get _subTotal => _items.fold(0, (sum, item) => sum + (item.unitPrice * item.quantity));
|
||||||
int get _tax => _includeTax ? (_subTotal * _taxRate).round() : 0;
|
int get _tax => _includeTax ? (_subTotal * _taxRate).floor() : 0;
|
||||||
int get _total => _subTotal + _tax;
|
int get _total => _subTotal + _tax;
|
||||||
|
|
||||||
Future<void> _saveInvoice({bool generatePdf = true}) async {
|
Future<void> _saveInvoice({bool generatePdf = true}) async {
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ class ManagementScreen extends StatelessWidget {
|
||||||
Icons.download,
|
Icons.download,
|
||||||
"伝票マスター・インポート",
|
"伝票マスター・インポート",
|
||||||
"外部ファイルからデータを取り込みます",
|
"外部ファイルからデータを取り込みます",
|
||||||
() => _showComingSoon(context),
|
() => _importCsv(context),
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
_buildSectionHeader("バックアップ & セキュリティ"),
|
_buildSectionHeader("バックアップ & セキュリティ"),
|
||||||
|
|
@ -81,7 +81,7 @@ class ManagementScreen extends StatelessWidget {
|
||||||
Icons.settings_backup_restore,
|
Icons.settings_backup_restore,
|
||||||
"データベース・リストア",
|
"データベース・リストア",
|
||||||
"バックアップから全てのデータを復元します",
|
"バックアップから全てのデータを復元します",
|
||||||
() => _showComingSoon(context),
|
() => _restoreDatabase(context),
|
||||||
),
|
),
|
||||||
_buildMenuTile(
|
_buildMenuTile(
|
||||||
context,
|
context,
|
||||||
|
|
@ -97,7 +97,7 @@ class ManagementScreen extends StatelessWidget {
|
||||||
Icons.sync,
|
Icons.sync,
|
||||||
"クラウド同期を実行",
|
"クラウド同期を実行",
|
||||||
"未同期の伝票をクラウドマスターへ送信します",
|
"未同期の伝票をクラウドマスターへ送信します",
|
||||||
() => _showComingSoon(context),
|
() => _syncWithCloud(context),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -124,12 +124,6 @@ class ManagementScreen extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showComingSoon(BuildContext context) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text("この機能は次期バージョンで実装予定です。同期フラグ等の基盤は準備済みです。")),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _exportAllInvoicesCsv(BuildContext context) async {
|
Future<void> _exportAllInvoicesCsv(BuildContext context) async {
|
||||||
final invoiceRepo = InvoiceRepository();
|
final invoiceRepo = InvoiceRepository();
|
||||||
final customerRepo = CustomerRepository();
|
final customerRepo = CustomerRepository();
|
||||||
|
|
@ -162,4 +156,44 @@ class ManagementScreen extends StatelessWidget {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("データベースファイルが見つかりません")));
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("データベースファイルが見つかりません")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _importCsv(BuildContext context) async {
|
||||||
|
// 将来的に file_picker 等を使用してファイルを選択する
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text("インポート"),
|
||||||
|
content: const Text("インポート用CSVファイルを選択してください。\n(現在この機能はファイル選択の基盤待ちです)"),
|
||||||
|
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text("閉じる"))],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _restoreDatabase(BuildContext context) async {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text("データベース・リストア"),
|
||||||
|
content: const Text("バックアップファイル(.db)を選択して上書き復元します。現在のデータは失われます。"),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context), child: const Text("ファイル選択", style: TextStyle(color: Colors.red))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _syncWithCloud(BuildContext context) async {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text("クラウド同期 (Odoo)"),
|
||||||
|
content: const Text("クラウドサーバーとデータを同期します。\n未同期項目: 5件"),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(context), child: const Text("閉じる")),
|
||||||
|
ElevatedButton(onPressed: () => Navigator.pop(context), child: const Text("実行")),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import 'package:sqflite/sqflite.dart';
|
||||||
import 'package:path/path.dart';
|
import 'package:path/path.dart';
|
||||||
|
|
||||||
class DatabaseHelper {
|
class DatabaseHelper {
|
||||||
static const _databaseVersion = 9;
|
static const _databaseVersion = 10;
|
||||||
static final DatabaseHelper _instance = DatabaseHelper._internal();
|
static final DatabaseHelper _instance = DatabaseHelper._internal();
|
||||||
static Database? _database;
|
static Database? _database;
|
||||||
|
|
||||||
|
|
@ -84,6 +84,10 @@ class DatabaseHelper {
|
||||||
if (oldVersion < 9) {
|
if (oldVersion < 9) {
|
||||||
await db.execute('ALTER TABLE company_info ADD COLUMN tax_display_mode TEXT DEFAULT "normal"');
|
await db.execute('ALTER TABLE company_info ADD COLUMN tax_display_mode TEXT DEFAULT "normal"');
|
||||||
}
|
}
|
||||||
|
if (oldVersion < 10) {
|
||||||
|
await db.execute('ALTER TABLE invoices ADD COLUMN terminal_id TEXT DEFAULT "T1"');
|
||||||
|
await db.execute('ALTER TABLE invoices ADD COLUMN content_hash TEXT');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onCreate(Database db, int version) async {
|
Future<void> _onCreate(Database db, int version) async {
|
||||||
|
|
@ -145,6 +149,8 @@ class DatabaseHelper {
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
latitude REAL,
|
latitude REAL,
|
||||||
longitude REAL,
|
longitude REAL,
|
||||||
|
terminal_id TEXT DEFAULT "T1",
|
||||||
|
content_hash TEXT,
|
||||||
FOREIGN KEY (customer_id) REFERENCES customers (id)
|
FOREIGN KEY (customer_id) REFERENCES customers (id)
|
||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ class InvoiceRepository {
|
||||||
updatedAt: DateTime.parse(iMap['updated_at']),
|
updatedAt: DateTime.parse(iMap['updated_at']),
|
||||||
latitude: iMap['latitude'],
|
latitude: iMap['latitude'],
|
||||||
longitude: iMap['longitude'],
|
longitude: iMap['longitude'],
|
||||||
|
terminalId: iMap['terminal_id'] ?? "T1",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return invoices;
|
return invoices;
|
||||||
|
|
|
||||||
|
|
@ -181,9 +181,10 @@ Future<pw.Document> buildInvoiceDocument(Invoice invoice) async {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// 備考
|
||||||
// 備考
|
// 備考
|
||||||
if (invoice.notes != null && invoice.notes!.isNotEmpty) ...[
|
if (invoice.notes != null && invoice.notes!.isNotEmpty) ...[
|
||||||
pw.SizedBox(height: 40),
|
pw.SizedBox(height: 10),
|
||||||
pw.Text("備考:", style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
|
pw.Text("備考:", style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
|
||||||
pw.Container(
|
pw.Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
|
|
@ -192,6 +193,31 @@ Future<pw.Document> buildInvoiceDocument(Invoice invoice) async {
|
||||||
child: pw.Text(invoice.notes!),
|
child: pw.Text(invoice.notes!),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
pw.SizedBox(height: 20),
|
||||||
|
// 監査用ハッシュとQRコード
|
||||||
|
pw.Row(
|
||||||
|
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||||
|
crossAxisAlignment: pw.CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
pw.Column(
|
||||||
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
pw.Text("Verification Hash (SHA256):", style: pw.TextStyle(fontSize: 8, color: PdfColors.grey700)),
|
||||||
|
pw.Text(invoice.contentHash, style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold, color: PdfColors.grey700)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
pw.Container(
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
child: pw.BarcodeWidget(
|
||||||
|
barcode: pw.Barcode.qrCode(),
|
||||||
|
data: invoice.contentHash,
|
||||||
|
drawText: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
footer: (context) => pw.Container(
|
footer: (context) => pw.Container(
|
||||||
alignment: pw.Alignment.centerRight,
|
alignment: pw.Alignment.centerRight,
|
||||||
|
|
@ -212,16 +238,15 @@ Future<String?> generateInvoicePdf(Invoice invoice) async {
|
||||||
try {
|
try {
|
||||||
final pdf = await buildInvoiceDocument(invoice);
|
final pdf = await buildInvoiceDocument(invoice);
|
||||||
|
|
||||||
// 保存処理
|
final String hash = invoice.contentHash;
|
||||||
final Uint8List bytes = await pdf.save();
|
|
||||||
final String hash = sha256.convert(bytes).toString().substring(0, 4);
|
|
||||||
final String timeStr = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
|
final String timeStr = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
|
||||||
String fileName = "${invoice.invoiceNumberPrefix}_${invoice.invoiceNumber}_${timeStr}_$hash.pdf";
|
String fileName = "${invoice.invoiceNumberPrefix}_${invoice.terminalId}_${invoice.id.substring(invoice.id.length - 4)}_${timeStr}_$hash.pdf";
|
||||||
|
|
||||||
final directory = await getExternalStorageDirectory();
|
final directory = await getExternalStorageDirectory();
|
||||||
if (directory == null) return null;
|
if (directory == null) return null;
|
||||||
|
|
||||||
final file = File("${directory.path}/$fileName");
|
final file = File("${directory.path}/$fileName");
|
||||||
|
final Uint8List bytes = await pdf.save();
|
||||||
await file.writeAsBytes(bytes);
|
await file.writeAsBytes(bytes);
|
||||||
|
|
||||||
// 生成をログに記録
|
// 生成をログに記録
|
||||||
|
|
|
||||||
|
|
@ -27,25 +27,41 @@ class _SlideToUnlockState extends State<SlideToUnlock> {
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final double maxWidth = constraints.maxWidth;
|
final double maxWidth = constraints.maxWidth;
|
||||||
final double trackWidth = maxWidth - _thumbSize;
|
final double trackWidth = maxWidth - _thumbSize - 8; // 余白を考慮
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
height: 60,
|
height: 64,
|
||||||
margin: const EdgeInsets.all(16),
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.blueGrey.shade100,
|
color: Colors.blueGrey.shade900,
|
||||||
borderRadius: BorderRadius.circular(30),
|
borderRadius: BorderRadius.circular(32),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(color: Colors.black26, blurRadius: 8, offset: const Offset(0, 4)),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
|
// 背景テキストとアニメーション効果(簡易)
|
||||||
Center(
|
Center(
|
||||||
child: Text(
|
child: Opacity(
|
||||||
widget.text,
|
opacity: (1 - (_position / trackWidth)).clamp(0.2, 1.0),
|
||||||
style: TextStyle(color: Colors.blueGrey.shade700, fontWeight: FontWeight.bold),
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.keyboard_double_arrow_right, color: Colors.white54, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
widget.text,
|
||||||
|
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, letterSpacing: 1.2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// スライドつまみ
|
||||||
Positioned(
|
Positioned(
|
||||||
left: _position,
|
left: _position + 4,
|
||||||
|
top: 4,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onHorizontalDragUpdate: (details) {
|
onHorizontalDragUpdate: (details) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -55,24 +71,32 @@ class _SlideToUnlockState extends State<SlideToUnlock> {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onHorizontalDragEnd: (details) {
|
onHorizontalDragEnd: (details) {
|
||||||
if (_position >= trackWidth * 0.9) {
|
if (_position >= trackWidth * 0.95) {
|
||||||
widget.onUnlocked();
|
widget.onUnlocked();
|
||||||
setState(() => _position = 0); // 念のためリセット
|
// 成功時はアニメーションで戻すのではなく、状態が変わるのでリセット
|
||||||
|
setState(() => _position = 0);
|
||||||
} else {
|
} else {
|
||||||
|
// 失敗時はバネのように戻る(簡易)
|
||||||
setState(() => _position = 0);
|
setState(() => _position = 0);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
width: _thumbSize,
|
width: maxWidth * 0.25, // 少し横長に
|
||||||
height: 60,
|
height: 56,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.orangeAccent,
|
gradient: const LinearGradient(
|
||||||
shape: BoxShape.circle,
|
colors: [Colors.orangeAccent, Colors.deepOrange],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(color: Colors.black.withOpacity(0.2), blurRadius: 4, offset: const Offset(2, 2)),
|
BoxShadow(color: Colors.black45, blurRadius: 4, offset: const Offset(2, 2)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.arrow_forward_ios, color: Colors.white),
|
child: const Center(
|
||||||
|
child: Icon(Icons.key, color: Colors.white, size: 24),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.0.1+1
|
version: 1.5.0+150
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.7
|
sdk: ^3.10.7
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue