From 25295fd6196a572fd0c5628a7c5fa4120088f139 Mon Sep 17 00:00:00 2001 From: joe Date: Sat, 14 Feb 2026 22:43:54 +0900 Subject: [PATCH] feat: Implement terminal ID and content hash for invoices, enhance PDF generation with QR codes, and refine slide-to-unlock UI. --- lib/main.dart | 4 +- lib/models/invoice_models.dart | 18 ++++++++- lib/screens/invoice_input_screen.dart | 2 +- lib/screens/management_screen.dart | 52 +++++++++++++++++++----- lib/services/database_helper.dart | 8 +++- lib/services/invoice_repository.dart | 1 + lib/services/pdf_generator.dart | 35 +++++++++++++--- lib/widgets/slide_to_unlock.dart | 58 +++++++++++++++++++-------- pubspec.yaml | 2 +- 9 files changed, 142 insertions(+), 38 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index b80c696..dd1e986 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 { 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( diff --git a/lib/models/invoice_models.dart b/lib/models/invoice_models.dart index 786295f..740f74a 100644 --- a/lib/models/invoice_models.dart +++ b/lib/models/invoice_models.dart @@ -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, // 追加 }; } diff --git a/lib/screens/invoice_input_screen.dart b/lib/screens/invoice_input_screen.dart index 7bb83fa..5b17189 100644 --- a/lib/screens/invoice_input_screen.dart +++ b/lib/screens/invoice_input_screen.dart @@ -74,7 +74,7 @@ class _InvoiceInputFormState extends State { } 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 _saveInvoice({bool generatePdf = true}) async { diff --git a/lib/screens/management_screen.dart b/lib/screens/management_screen.dart index 9b6e645..9242e69 100644 --- a/lib/screens/management_screen.dart +++ b/lib/screens/management_screen.dart @@ -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 _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 _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 _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 _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("実行")), + ], + ), + ); + } } diff --git a/lib/services/database_helper.dart b/lib/services/database_helper.dart index a0d15d1..5cbc5b1 100644 --- a/lib/services/database_helper.dart +++ b/lib/services/database_helper.dart @@ -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 _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) ) '''); diff --git a/lib/services/invoice_repository.dart b/lib/services/invoice_repository.dart index a9dc485..6024020 100644 --- a/lib/services/invoice_repository.dart +++ b/lib/services/invoice_repository.dart @@ -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; diff --git a/lib/services/pdf_generator.dart b/lib/services/pdf_generator.dart index 6ef3a23..2acbf6a 100644 --- a/lib/services/pdf_generator.dart +++ b/lib/services/pdf_generator.dart @@ -181,9 +181,10 @@ Future 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 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 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); // 生成をログに記録 diff --git a/lib/widgets/slide_to_unlock.dart b/lib/widgets/slide_to_unlock.dart index 94936c2..7aa023f 100644 --- a/lib/widgets/slide_to_unlock.dart +++ b/lib/widgets/slide_to_unlock.dart @@ -27,25 +27,41 @@ class _SlideToUnlockState extends State { 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 { }); }, 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), + ), ), ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 2a2ad7b..9492ed0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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