Newer
Older
SmTIAS-Capture / lib / services / file_service.dart
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')}';

  /// PNG バイト列をファイルに書き込んで保存する.
  ///
  /// Android ネイティブ側で YUV→RGB→PNG 変換済みのバイト列を受け取り,
  /// そのまま書き込むため高速に動作する.
  Future<String> saveImage(Uint8List pngBytes) async {
    await _ensureDirectory();
    final fileName = await generateFileName();
    final filePath = '$directoryPath/$fileName';
    await File(filePath).writeAsBytes(pngBytes);
    return filePath;
  }

  /// 候補名が衝突した場合に `_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';
  }

  /// ファイル名を生成する.
  ///
  /// 形式: MiniTIAS_YYYYMMDD_HHmmss.png
  /// 同秒の重複がある場合は _1, _2, ... と連番を付与する.
  Future<String> generateFileName() async {
    final baseName = 'MiniTIAS_${_buildTimestamp(DateTime.now())}';
    final resolved = await _resolveUniqueNameAsync(
      baseName,
      (name) => File('$directoryPath/$name.png').exists(),
    );
    return '$resolved.png';
  }

  /// カメラ診断情報を 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;
  }

  /// テスト用: タイムスタンプとファイル名一覧からファイル名を生成する.
  static String generateFileNameSync(
    String timestamp,
    List<String> existingFiles,
  ) {
    final baseName = 'MiniTIAS_$timestamp';
    final resolved = _resolveUniqueName(
      baseName,
      (name) => existingFiles.contains('$name.png'),
    );
    return '$resolved.png';
  }

  /// 定量モード撮影用のファイル名 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;
  }
}