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:
joe 2026-02-14 22:43:54 +09:00
parent 035baf9078
commit 25295fd619
9 changed files with 142 additions and 38 deletions

View file

@ -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(

View file

@ -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, //
};
}

View file

@ -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 {

View file

@ -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("実行")),
],
),
);
}
}

View file

@ -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)
)
''');

View file

@ -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;

View file

@ -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);
//

View file

@ -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(
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: TextStyle(color: Colors.blueGrey.shade700, fontWeight: FontWeight.bold),
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),
),
),
),
),

View file

@ -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