import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:smtias_capture/constants/storage_paths.dart';
/// 撮影画像の保存・命名・削除を行うサービス.
class FileService {
/// 保存先ディレクトリは省略可能(省略時は本番の共有ストレージパス).
///
/// テストでは一時ディレクトリを渡して実メソッドを直接検証できる.
FileService({String? directoryPath})
: _directoryPath = directoryPath ?? defaultCaptureBasePath;
final String _directoryPath;
/// 保存先ディレクトリのパスを返す.
String get directoryPath => _directoryPath;
/// [directoryPath] が存在しない場合,再帰的に作成する.
Future<void> _ensureDirectory() async {
final directory = Directory(directoryPath);
if (!await directory.exists()) {
await directory.create(recursive: true);
}
}
/// `YYYYMMDD_HHmmss` 形式のタイムスタンプ文字列を生成する.
static String _buildTimestamp(DateTime dt) =>
'${dt.year}'
'${dt.month.toString().padLeft(2, '0')}'
'${dt.day.toString().padLeft(2, '0')}'
'_'
'${dt.hour.toString().padLeft(2, '0')}'
'${dt.minute.toString().padLeft(2, '0')}'
'${dt.second.toString().padLeft(2, '0')}';
/// 候補名が衝突した場合に `_1`, `_2`, ... と連番サフィックスを付与し,
/// 一意な名前を決定する(同期版).
///
/// [baseName] 自体が衝突しなければ [baseName] を返し,衝突する場合は
/// suffix=1 から順に空きを探して `${baseName}_$suffix` を返す.
/// 衝突判定(拡張子の付与等)は [exists] クロージャが吸収する.
static String _resolveUniqueName(
String baseName,
bool Function(String name) exists,
) {
if (!exists(baseName)) {
return baseName;
}
var suffix = 1;
while (exists('${baseName}_$suffix')) {
suffix++;
}
return '${baseName}_$suffix';
}
/// [_resolveUniqueName] の非同期版.[exists] が `Future<bool>` を返す点のみが異なる.
static Future<String> _resolveUniqueNameAsync(
String baseName,
Future<bool> Function(String name) exists,
) async {
if (!await exists(baseName)) {
return baseName;
}
var suffix = 1;
while (await exists('${baseName}_$suffix')) {
suffix++;
}
return '${baseName}_$suffix';
}
/// カメラ診断情報を JSON ファイルとして保存する.
///
/// ファイル名: `camera_diagnostics_YYYYMMDD_HHmmss.json`
/// 保存先: `/storage/emulated/0/Pictures/MiniTIAS`
/// 戻り値は保存したファイルのフルパス.
Future<String> saveDiagnosticsJson(Map<String, dynamic> diagnostics) async {
await _ensureDirectory();
final fileName =
'camera_diagnostics_${_buildTimestamp(DateTime.now())}.json';
final filePath = '$directoryPath/$fileName';
const encoder = JsonEncoder.withIndent(' ');
await File(filePath).writeAsString(encoder.convert(diagnostics));
return filePath;
}
/// 定量モード撮影用のファイル名 prefix を生成する.
///
/// 形式: `MiniTIAS_QM_YYYYMMDD_HHmmss`
/// 同秒の重複がある場合は `_1`, `_2` ...と連番を付与する.
/// 重複判定は `<baseName>.dng` の存在チェックで行う.
Future<String> generateQuantitativeBaseName() async {
final baseName = 'MiniTIAS_QM_${_buildTimestamp(DateTime.now())}';
return _resolveUniqueNameAsync(
baseName,
(name) => File('$directoryPath/$name.dng').exists(),
);
}
/// テスト用同期版.既存ファイル名のリストとタイムスタンプを受け取って baseName を返す.
///
/// 候補 `MiniTIAS_QM_{timestamp}` に対して `.dng` の存在チェックを行う.
static String generateQuantitativeBaseNameSync(
String timestamp,
List<String> existingFiles,
) {
final baseName = 'MiniTIAS_QM_$timestamp';
return _resolveUniqueName(
baseName,
(name) => existingFiles.contains('$name.dng'),
);
}
/// ライブプレビューのスナップショット JPEG を `<baseName>.preview.jpg` として保存する.
///
/// [previewBytes] は JPEG エンコード済みのバイト列.
/// 戻り値は保存したファイルのフルパス.
Future<String> savePreviewJpeg(
String baseName,
Uint8List previewBytes,
) async {
await _ensureDirectory();
final filePath = '$directoryPath/$baseName.preview.jpg';
await File(filePath).writeAsBytes(previewBytes);
return filePath;
}
/// メタデータと LSC マップを `<baseName>.meta.json` として保存する.
///
/// [lscMap] が null の場合,`lscMap`/`lscMapRowCount`/`lscMapColumnCount` キーは含めない.
Future<String> saveQuantitativeMetadata(
String baseName,
Map<String, dynamic> metadata, {
List<List<double>>? lscMap,
int? lscMapRowCount,
int? lscMapColumnCount,
}) async {
await _ensureDirectory();
final Map<String, dynamic> jsonMap = Map<String, dynamic>.from(metadata);
if (lscMap != null) {
jsonMap['lscMap'] = lscMap;
jsonMap['lscMapRowCount'] = lscMapRowCount;
jsonMap['lscMapColumnCount'] = lscMapColumnCount;
}
const encoder = JsonEncoder.withIndent(' ');
final filePath = '$directoryPath/$baseName.meta.json';
await File(filePath).writeAsString(encoder.convert(jsonMap));
return filePath;
}
}