feat(media): gestion des médias locaux

Ajoute le stockage local des médias (infrastructure/local/local_media_storage.dart)
et les use cases/ports associés. flutter analyze propre, 7/7 tests
unitaires verts, build APK debug validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 18:31:28 +02:00
parent 2499a95e08
commit c77006f322
6 changed files with 591 additions and 0 deletions

View File

@ -35,6 +35,35 @@ final class DriftExerciseRepository implements ExerciseRepository {
}
}
final class DriftMediaAssetRepository implements MediaAssetRepository {
const DriftMediaAssetRepository(this.database);
final db.AppDatabase database;
@override
Future<domain.MediaAsset?> findById(String id) async {
final row = await (database.select(
database.mediaAssets,
)..where((table) => table.id.equals(id))).getSingleOrNull();
return row == null ? null : _mediaAssetFromRow(row);
}
@override
Future<List<domain.MediaAsset>> listActive() async {
final rows = await (database.select(
database.mediaAssets,
)..where((table) => table.deletedAt.isNull())).get();
return rows.map(_mediaAssetFromRow).toList();
}
@override
Future<void> save(domain.MediaAsset mediaAsset) async {
await database
.into(database.mediaAssets)
.insertOnConflictUpdate(_mediaAssetCompanion(mediaAsset));
}
}
final class DriftProgramRepository implements ProgramRepository {
const DriftProgramRepository(this.database);
@ -294,6 +323,49 @@ domain.Exercise _exerciseFromRow(db.Exercise row) {
);
}
db.MediaAssetsCompanion _mediaAssetCompanion(domain.MediaAsset asset) {
final values = _metadataValues(asset.metadata);
return db.MediaAssetsCompanion(
id: values[0] as Value<String>,
createdAt: values[1] as Value<DateTime>,
updatedAt: values[2] as Value<DateTime>,
deletedAt: values[3] as Value<DateTime?>,
schemaVersion: values[4] as Value<int>,
syncState: values[5] as Value<String>,
localRevision: values[6] as Value<int>,
originDeviceId: values[7] as Value<String>,
futureOwnerProfileId: values[8] as Value<String?>,
lastSyncedAt: values[9] as Value<DateTime?>,
remoteRevision: values[10] as Value<String?>,
kind: Value(_mediaKindToDb(asset.kind)),
localUri: Value(asset.localUri),
mimeType: Value(asset.mimeType),
sizeBytes: Value(asset.sizeBytes),
width: Value(asset.width),
height: Value(asset.height),
durationMs: Value(asset.durationMs),
checksum: Value(asset.checksum),
remoteUri: Value(asset.remoteUri),
thumbnailLocalUri: Value(asset.thumbnailLocalUri),
);
}
domain.MediaAsset _mediaAssetFromRow(db.MediaAsset row) {
return domain.MediaAsset(
metadata: _metadataFromRow(row),
kind: _mediaKindFromDb(row.kind),
localUri: row.localUri,
mimeType: row.mimeType,
sizeBytes: row.sizeBytes,
width: row.width,
height: row.height,
durationMs: row.durationMs,
checksum: row.checksum,
remoteUri: row.remoteUri,
thumbnailLocalUri: row.thumbnailLocalUri,
);
}
db.ProgramsCompanion _programCompanion(domain.Program program) {
final values = _metadataValues(program.metadata);
return db.ProgramsCompanion(
@ -741,6 +813,17 @@ String _activeStatusToDb(domain.ActiveWorkoutStatus status) => switch (status) {
domain.ActiveWorkoutStatus.abandoned => 'abandoned',
};
String _mediaKindToDb(domain.MediaKind kind) => switch (kind) {
domain.MediaKind.image => 'image',
domain.MediaKind.video => 'video',
};
domain.MediaKind _mediaKindFromDb(String value) => switch (value) {
'image' => domain.MediaKind.image,
'video' => domain.MediaKind.video,
_ => throw domain.DomainException('Unknown media kind: $value'),
};
domain.ActiveWorkoutStatus _activeStatusFromDb(String value) => switch (value) {
'running' => domain.ActiveWorkoutStatus.running,
'paused' => domain.ActiveWorkoutStatus.paused,

View File

@ -1,3 +1,4 @@
export 'app_database.dart';
export 'drift_repositories.dart';
export 'local_id.dart';
export 'local_media_storage.dart';

View File

@ -0,0 +1,167 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../../application/application.dart';
import '../../domain/domain.dart';
final class PathProviderLocalMediaStorage implements LocalMediaStorage {
const PathProviderLocalMediaStorage();
static const _mediaDirectoryName = 'gametime_media';
@override
Future<StoredMediaFile> importFile({
required String sourcePath,
required MediaKind kind,
required String stableFileName,
}) async {
final source = File(sourcePath);
if (!await source.exists()) {
throw DomainException('Media source file not found: $sourcePath');
}
final extension = p.extension(source.path).toLowerCase();
final targetDirectory = await _kindDirectory(kind);
await targetDirectory.create(recursive: true);
final target = File(
p.join(targetDirectory.path, '$stableFileName$extension'),
);
await source.copy(target.path);
final bytes = await target.length();
final dimensions = kind == MediaKind.image
? await _tryReadImageDimensions(target)
: null;
return StoredMediaFile(
localUri: target.uri.toString(),
mimeType: _mimeTypeForExtension(extension, kind),
sizeBytes: bytes,
width: dimensions?.width,
height: dimensions?.height,
durationMs: null,
);
}
@override
Future<Set<String>> listManagedLocalUris() async {
final root = await _rootDirectory();
if (!await root.exists()) {
return const {};
}
final files = await root
.list(recursive: true)
.where((entity) => entity is File)
.cast<File>()
.toList();
return files.map((file) => file.uri.toString()).toSet();
}
@override
Future<void> deleteByLocalUri(String localUri) async {
final file = File.fromUri(Uri.parse(localUri));
final root = await _rootDirectory();
final filePath = p.normalize(file.absolute.path);
final rootPath = p.normalize(root.absolute.path);
if (!p.isWithin(rootPath, filePath) && filePath != rootPath) {
throw DomainException(
'Refusing to delete unmanaged media file: $localUri',
);
}
if (await file.exists()) {
await file.delete();
}
}
Future<Directory> _kindDirectory(MediaKind kind) async {
final root = await _rootDirectory();
final child = switch (kind) {
MediaKind.image => 'images',
MediaKind.video => 'videos',
};
return Directory(p.join(root.path, child));
}
Future<Directory> _rootDirectory() async {
final documents = await getApplicationDocumentsDirectory();
return Directory(p.join(documents.path, _mediaDirectoryName));
}
}
final class _ImageDimensions {
const _ImageDimensions(this.width, this.height);
final int width;
final int height;
}
Future<_ImageDimensions?> _tryReadImageDimensions(File file) async {
final bytes = await file.openRead(0, 32).fold<BytesBuilder>(
BytesBuilder(copy: false),
(builder, chunk) {
builder.add(chunk);
return builder;
},
);
final data = bytes.toBytes();
return _readPngDimensions(data) ?? await _readJpegDimensions(file);
}
_ImageDimensions? _readPngDimensions(Uint8List data) {
const signature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
if (data.length < 24) {
return null;
}
for (var index = 0; index < signature.length; index++) {
if (data[index] != signature[index]) {
return null;
}
}
final view = ByteData.sublistView(data);
return _ImageDimensions(view.getUint32(16), view.getUint32(20));
}
Future<_ImageDimensions?> _readJpegDimensions(File file) async {
final data = await file.readAsBytes();
if (data.length < 4 || data[0] != 0xFF || data[1] != 0xD8) {
return null;
}
var offset = 2;
while (offset + 9 < data.length) {
if (data[offset] != 0xFF) {
return null;
}
final marker = data[offset + 1];
final length = (data[offset + 2] << 8) + data[offset + 3];
if (length < 2 || offset + 2 + length > data.length) {
return null;
}
if ((marker >= 0xC0 && marker <= 0xC3) ||
(marker >= 0xC5 && marker <= 0xC7) ||
(marker >= 0xC9 && marker <= 0xCB) ||
(marker >= 0xCD && marker <= 0xCF)) {
final height = (data[offset + 5] << 8) + data[offset + 6];
final width = (data[offset + 7] << 8) + data[offset + 8];
return _ImageDimensions(width, height);
}
offset += 2 + length;
}
return null;
}
String? _mimeTypeForExtension(String extension, MediaKind kind) {
return switch (extension) {
'.jpg' || '.jpeg' => 'image/jpeg',
'.png' => 'image/png',
'.gif' => 'image/gif',
'.webp' => 'image/webp',
'.mp4' => 'video/mp4',
'.mov' => 'video/quicktime',
'.webm' => 'video/webm',
_ => kind == MediaKind.image ? 'image/*' : 'video/*',
};
}