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
|
||||
// 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';
|
||||
|
||||
// --- 独自モジュールのインポート ---
|
||||
|
|
@ -56,7 +56,7 @@ class _InvoiceFlowScreenState extends State<InvoiceFlowScreen> {
|
|||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("販売アシスト1号 V1.4.3c"),
|
||||
title: const Text("販売アシスト1号 V1.5.01"),
|
||||
backgroundColor: Colors.blueGrey,
|
||||
),
|
||||
drawer: Drawer(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import 'customer_model.dart';
|
||||
import 'dart:convert';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'customer_model.dart';
|
||||
|
||||
class InvoiceItem {
|
||||
final String? id;
|
||||
|
|
@ -62,6 +64,7 @@ class Invoice {
|
|||
final DateTime updatedAt;
|
||||
final double? latitude; // 追加
|
||||
final double? longitude; // 追加
|
||||
final String terminalId; // 追加: 端末識別子
|
||||
|
||||
Invoice({
|
||||
String? id,
|
||||
|
|
@ -78,9 +81,18 @@ class Invoice {
|
|||
DateTime? updatedAt,
|
||||
this.latitude, // 追加
|
||||
this.longitude, // 追加
|
||||
String? terminalId, // 追加
|
||||
}) : id = id ?? DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
terminalId = terminalId ?? "T1", // デフォルト端末ID
|
||||
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 {
|
||||
switch (documentType) {
|
||||
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;
|
||||
|
|
@ -124,6 +136,8 @@ class Invoice {
|
|||
'updated_at': updatedAt.toIso8601String(),
|
||||
'latitude': latitude, // 追加
|
||||
'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 _tax => _includeTax ? (_subTotal * _taxRate).round() : 0;
|
||||
int get _tax => _includeTax ? (_subTotal * _taxRate).floor() : 0;
|
||||
int get _total => _subTotal + _tax;
|
||||
|
||||
Future<void> _saveInvoice({bool generatePdf = true}) async {
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ class ManagementScreen extends StatelessWidget {
|
|||
Icons.download,
|
||||
"伝票マスター・インポート",
|
||||
"外部ファイルからデータを取り込みます",
|
||||
() => _showComingSoon(context),
|
||||
() => _importCsv(context),
|
||||
),
|
||||
const Divider(),
|
||||
_buildSectionHeader("バックアップ & セキュリティ"),
|
||||
|
|
@ -81,7 +81,7 @@ class ManagementScreen extends StatelessWidget {
|
|||
Icons.settings_backup_restore,
|
||||
"データベース・リストア",
|
||||
"バックアップから全てのデータを復元します",
|
||||
() => _showComingSoon(context),
|
||||
() => _restoreDatabase(context),
|
||||
),
|
||||
_buildMenuTile(
|
||||
context,
|
||||
|
|
@ -97,7 +97,7 @@ class ManagementScreen extends StatelessWidget {
|
|||
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 {
|
||||
final invoiceRepo = InvoiceRepository();
|
||||
final customerRepo = CustomerRepository();
|
||||
|
|
@ -162,4 +156,44 @@ class ManagementScreen extends StatelessWidget {
|
|||
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';
|
||||
|
||||
class DatabaseHelper {
|
||||
static const _databaseVersion = 9;
|
||||
static const _databaseVersion = 10;
|
||||
static final DatabaseHelper _instance = DatabaseHelper._internal();
|
||||
static Database? _database;
|
||||
|
||||
|
|
@ -84,6 +84,10 @@ class DatabaseHelper {
|
|||
if (oldVersion < 9) {
|
||||
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 {
|
||||
|
|
@ -145,6 +149,8 @@ class DatabaseHelper {
|
|||
updated_at TEXT NOT NULL,
|
||||
latitude REAL,
|
||||
longitude REAL,
|
||||
terminal_id TEXT DEFAULT "T1",
|
||||
content_hash TEXT,
|
||||
FOREIGN KEY (customer_id) REFERENCES customers (id)
|
||||
)
|
||||
''');
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ class InvoiceRepository {
|
|||
updatedAt: DateTime.parse(iMap['updated_at']),
|
||||
latitude: iMap['latitude'],
|
||||
longitude: iMap['longitude'],
|
||||
terminalId: iMap['terminal_id'] ?? "T1",
|
||||
));
|
||||
}
|
||||
return invoices;
|
||||
|
|
|
|||
|
|
@ -181,9 +181,10 @@ Future<pw.Document> buildInvoiceDocument(Invoice invoice) async {
|
|||
],
|
||||
),
|
||||
|
||||
// 備考
|
||||
// 備考
|
||||
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.Container(
|
||||
width: double.infinity,
|
||||
|
|
@ -192,6 +193,31 @@ Future<pw.Document> buildInvoiceDocument(Invoice invoice) async {
|
|||
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(
|
||||
alignment: pw.Alignment.centerRight,
|
||||
|
|
@ -212,16 +238,15 @@ Future<String?> generateInvoicePdf(Invoice invoice) async {
|
|||
try {
|
||||
final pdf = await buildInvoiceDocument(invoice);
|
||||
|
||||
// 保存処理
|
||||
final Uint8List bytes = await pdf.save();
|
||||
final String hash = sha256.convert(bytes).toString().substring(0, 4);
|
||||
final String hash = invoice.contentHash;
|
||||
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();
|
||||
if (directory == null) return null;
|
||||
|
||||
final file = File("${directory.path}/$fileName");
|
||||
final Uint8List bytes = await pdf.save();
|
||||
await file.writeAsBytes(bytes);
|
||||
|
||||
// 生成をログに記録
|
||||
|
|
|
|||
|
|
@ -27,25 +27,41 @@ class _SlideToUnlockState extends State<SlideToUnlock> {
|
|||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final double maxWidth = constraints.maxWidth;
|
||||
final double trackWidth = maxWidth - _thumbSize;
|
||||
final double trackWidth = maxWidth - _thumbSize - 8; // 余白を考慮
|
||||
|
||||
return Container(
|
||||
height: 60,
|
||||
margin: const EdgeInsets.all(16),
|
||||
height: 64,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blueGrey.shade100,
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
color: Colors.blueGrey.shade900,
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black26, blurRadius: 8, offset: const Offset(0, 4)),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// 背景テキストとアニメーション効果(簡易)
|
||||
Center(
|
||||
child: Text(
|
||||
widget.text,
|
||||
style: TextStyle(color: Colors.blueGrey.shade700, fontWeight: FontWeight.bold),
|
||||
child: Opacity(
|
||||
opacity: (1 - (_position / trackWidth)).clamp(0.2, 1.0),
|
||||
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(
|
||||
left: _position,
|
||||
left: _position + 4,
|
||||
top: 4,
|
||||
child: GestureDetector(
|
||||
onHorizontalDragUpdate: (details) {
|
||||
setState(() {
|
||||
|
|
@ -55,24 +71,32 @@ class _SlideToUnlockState extends State<SlideToUnlock> {
|
|||
});
|
||||
},
|
||||
onHorizontalDragEnd: (details) {
|
||||
if (_position >= trackWidth * 0.9) {
|
||||
if (_position >= trackWidth * 0.95) {
|
||||
widget.onUnlocked();
|
||||
setState(() => _position = 0); // 念のためリセット
|
||||
// 成功時はアニメーションで戻すのではなく、状態が変わるのでリセット
|
||||
setState(() => _position = 0);
|
||||
} else {
|
||||
// 失敗時はバネのように戻る(簡易)
|
||||
setState(() => _position = 0);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
width: _thumbSize,
|
||||
height: 60,
|
||||
width: maxWidth * 0.25, // 少し横長に
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orangeAccent,
|
||||
shape: BoxShape.circle,
|
||||
gradient: const LinearGradient(
|
||||
colors: [Colors.orangeAccent, Colors.deepOrange],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
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
|
||||
# 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.
|
||||
version: 1.0.1+1
|
||||
version: 1.5.0+150
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.7
|
||||
|
|
|
|||
Loading…
Reference in a new issue