175 lines
5.0 KiB
Dart
175 lines
5.0 KiB
Dart
|
|
import 'dart:convert';
|
||
|
|
import 'dart:io';
|
||
|
|
import 'dart:typed_data';
|
||
|
|
|
||
|
|
import 'package:path/path.dart' as p;
|
||
|
|
import 'package:path_provider/path_provider.dart';
|
||
|
|
|
||
|
|
class FeedbackEntry {
|
||
|
|
const FeedbackEntry({
|
||
|
|
required this.id,
|
||
|
|
required this.accurate,
|
||
|
|
required this.createdAt,
|
||
|
|
required this.photoPath,
|
||
|
|
required this.cropPath,
|
||
|
|
this.matchCardId,
|
||
|
|
this.matchScore,
|
||
|
|
this.matchDistance,
|
||
|
|
this.matchFilename,
|
||
|
|
this.matchConfident,
|
||
|
|
});
|
||
|
|
|
||
|
|
final String id;
|
||
|
|
final bool accurate;
|
||
|
|
final DateTime createdAt;
|
||
|
|
final String photoPath;
|
||
|
|
final String cropPath;
|
||
|
|
final String? matchCardId;
|
||
|
|
final double? matchScore;
|
||
|
|
final int? matchDistance;
|
||
|
|
final String? matchFilename;
|
||
|
|
final bool? matchConfident;
|
||
|
|
|
||
|
|
String? get matchAssetPath =>
|
||
|
|
matchFilename == null ? null : 'test_assets/$matchFilename';
|
||
|
|
|
||
|
|
Map<String, dynamic> toJson() => {
|
||
|
|
'id': id,
|
||
|
|
'accurate': accurate,
|
||
|
|
'createdAt': createdAt.toIso8601String(),
|
||
|
|
'photoFile': p.basename(photoPath),
|
||
|
|
'cropFile': p.basename(cropPath),
|
||
|
|
'matchCardId': matchCardId,
|
||
|
|
'matchScore': matchScore,
|
||
|
|
'matchDistance': matchDistance,
|
||
|
|
'matchFilename': matchFilename,
|
||
|
|
'matchConfident': matchConfident,
|
||
|
|
};
|
||
|
|
|
||
|
|
static FeedbackEntry fromJson(Map<String, dynamic> json, String dir) {
|
||
|
|
return FeedbackEntry(
|
||
|
|
id: json['id'] as String,
|
||
|
|
accurate: json['accurate'] as bool? ?? false,
|
||
|
|
createdAt: DateTime.tryParse(json['createdAt'] as String? ?? '') ??
|
||
|
|
DateTime.fromMillisecondsSinceEpoch(0),
|
||
|
|
photoPath: p.join(dir, json['photoFile'] as String),
|
||
|
|
cropPath: p.join(dir, json['cropFile'] as String),
|
||
|
|
matchCardId: json['matchCardId'] as String?,
|
||
|
|
matchScore: (json['matchScore'] as num?)?.toDouble(),
|
||
|
|
matchDistance: json['matchDistance'] as int?,
|
||
|
|
matchFilename: json['matchFilename'] as String?,
|
||
|
|
matchConfident: json['matchConfident'] as bool?,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Persists scan feedback under app documents (`feedback/`).
|
||
|
|
class FeedbackStore {
|
||
|
|
FeedbackStore._();
|
||
|
|
static final FeedbackStore instance = FeedbackStore._();
|
||
|
|
|
||
|
|
Future<Directory> _dir() async {
|
||
|
|
final root = await getApplicationDocumentsDirectory();
|
||
|
|
final dir = Directory(p.join(root.path, 'feedback'));
|
||
|
|
if (!await dir.exists()) {
|
||
|
|
await dir.create(recursive: true);
|
||
|
|
}
|
||
|
|
return dir;
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<File> _indexFile() async {
|
||
|
|
final dir = await _dir();
|
||
|
|
return File(p.join(dir.path, 'index.json'));
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<List<FeedbackEntry>> list() async {
|
||
|
|
final dir = await _dir();
|
||
|
|
final index = await _indexFile();
|
||
|
|
if (!await index.exists()) return const [];
|
||
|
|
try {
|
||
|
|
final raw = jsonDecode(await index.readAsString());
|
||
|
|
if (raw is! List) return const [];
|
||
|
|
final items = raw
|
||
|
|
.whereType<Map>()
|
||
|
|
.map((e) => FeedbackEntry.fromJson(Map<String, dynamic>.from(e), dir.path))
|
||
|
|
.toList();
|
||
|
|
items.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||
|
|
return items;
|
||
|
|
} catch (_) {
|
||
|
|
return const [];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<FeedbackEntry> add({
|
||
|
|
required bool accurate,
|
||
|
|
required String photoSourcePath,
|
||
|
|
required Uint8List cropBytes,
|
||
|
|
String? matchCardId,
|
||
|
|
double? matchScore,
|
||
|
|
int? matchDistance,
|
||
|
|
String? matchFilename,
|
||
|
|
bool? matchConfident,
|
||
|
|
}) async {
|
||
|
|
final dir = await _dir();
|
||
|
|
final id = DateTime.now().millisecondsSinceEpoch.toString();
|
||
|
|
final photoPath = p.join(dir.path, '${id}_photo.jpg');
|
||
|
|
final cropPath = p.join(dir.path, '${id}_crop.jpg');
|
||
|
|
|
||
|
|
await File(photoSourcePath).copy(photoPath);
|
||
|
|
await File(cropPath).writeAsBytes(cropBytes, flush: true);
|
||
|
|
|
||
|
|
final entry = FeedbackEntry(
|
||
|
|
id: id,
|
||
|
|
accurate: accurate,
|
||
|
|
createdAt: DateTime.now(),
|
||
|
|
photoPath: photoPath,
|
||
|
|
cropPath: cropPath,
|
||
|
|
matchCardId: matchCardId,
|
||
|
|
matchScore: matchScore,
|
||
|
|
matchDistance: matchDistance,
|
||
|
|
matchFilename: matchFilename,
|
||
|
|
matchConfident: matchConfident,
|
||
|
|
);
|
||
|
|
|
||
|
|
final existing = await list();
|
||
|
|
final next = [entry, ...existing];
|
||
|
|
final index = await _indexFile();
|
||
|
|
await index.writeAsString(
|
||
|
|
const JsonEncoder.withIndent(' ').convert(
|
||
|
|
next.map((e) => e.toJson()).toList(),
|
||
|
|
),
|
||
|
|
flush: true,
|
||
|
|
);
|
||
|
|
return entry;
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> delete(String id) async {
|
||
|
|
final dir = await _dir();
|
||
|
|
final existing = await list();
|
||
|
|
final keep = existing.where((e) => e.id != id).toList();
|
||
|
|
// Remove files for this entry.
|
||
|
|
for (final suffix in ['_photo.jpg', '_crop.jpg']) {
|
||
|
|
final f = File(p.join(dir.path, '$id$suffix'));
|
||
|
|
try { await f.delete(); } catch (_) {}
|
||
|
|
}
|
||
|
|
final index = await _indexFile();
|
||
|
|
if (keep.isEmpty) {
|
||
|
|
try { await index.delete(); } catch (_) {}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
await index.writeAsString(
|
||
|
|
const JsonEncoder.withIndent(' ').convert(
|
||
|
|
keep.map((e) => e.toJson()).toList(),
|
||
|
|
),
|
||
|
|
flush: true,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> clearAll() async {
|
||
|
|
final dir = await _dir();
|
||
|
|
if (await dir.exists()) {
|
||
|
|
await dir.delete(recursive: true);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|