mp4 suppor added

master
gitea 2 months ago
parent fca1326f5e
commit 78d729f523

@ -280,6 +280,115 @@ class BlossomService implements MediaServiceInterface {
}; };
} }
/// Uploads a video file (implements MediaServiceInterface).
@override
Future<Map<String, dynamic>> uploadVideo(File videoFile) async {
if (_nostrKeyPair == null) {
Logger.error('Blossom upload failed: Nostr keypair not set');
throw BlossomException('Nostr keypair not set. Call setNostrKeyPair() first.');
}
Logger.debug('Blossom video upload: Nostr keypair is set (pubkey: ${_nostrKeyPair!.publicKey.substring(0, 16)}...)');
try {
if (!await videoFile.exists()) {
throw BlossomException('Video file does not exist: ${videoFile.path}');
}
// Read file bytes and calculate SHA256 hash
final fileBytes = await videoFile.readAsBytes();
final hash = sha256.convert(fileBytes);
final blobHash = hash.toString();
// Get filename for the authorization event
final filename = videoFile.path.split('/').last;
// Determine content type for video (MP4)
final contentType = 'video/mp4';
// Create authorization event with blob hash and filename
final authEvent = _createAuthorizationEvent(blobHash, filename: filename);
// Encode authorization event as JSON object
final authEventJson = {
'created_at': authEvent.createdAt,
'content': authEvent.content,
'tags': authEvent.tags,
'kind': authEvent.kind,
'pubkey': authEvent.pubkey,
'id': authEvent.id,
'sig': authEvent.sig,
};
final authJsonString = jsonEncode(authEventJson);
final authBase64 = base64Encode(utf8.encode(authJsonString));
// Try different authorization header formats
final formats = [
('Nostr Base64', 'Nostr $authBase64'),
('Raw JSON', authJsonString),
('Base64', authBase64),
];
DioException? lastException;
for (final (formatName, authHeader) in formats) {
try {
final response = await _dio.put(
'/upload',
data: fileBytes,
options: Options(
headers: {
'Authorization': authHeader,
'Content-Type': contentType,
},
),
);
if (response.statusCode != 200 && response.statusCode != 201) {
throw BlossomException(
'Upload failed: ${response.statusMessage}',
response.statusCode,
);
}
final responseData = response.data as Map<String, dynamic>;
final uploadResponse = BlossomUploadResponse.fromJson(responseData);
Logger.info('Video uploaded to Blossom: ${uploadResponse.hash}');
return {
'hash': uploadResponse.hash,
'id': uploadResponse.hash,
'url': uploadResponse.url,
};
} on DioException catch (e) {
lastException = e;
if (e.response?.statusCode == 401) {
Logger.debug('Blossom format "$formatName" failed with 401, trying next format...');
continue;
}
// Non-401 error, rethrow
rethrow;
}
}
if (lastException != null) {
throw BlossomException(
'Video upload failed: ${lastException.message ?? 'Unknown error'}',
lastException.response?.statusCode,
);
}
throw BlossomException('Video upload failed: All authorization formats failed');
} on DioException catch (e) {
Logger.error('Blossom video upload DioException: ${e.message}');
throw BlossomException(
'Video upload failed: ${e.message ?? 'Unknown error'}',
e.response?.statusCode,
);
} catch (e) {
Logger.error('Blossom video upload error: $e');
throw BlossomException('Video upload failed: $e');
}
}
/// Fetches image bytes for a blob by hash with local caching. /// Fetches image bytes for a blob by hash with local caching.
/// ///
/// [blobHash] - The SHA256 hash of the blob (or full URL). /// [blobHash] - The SHA256 hash of the blob (or full URL).

@ -272,6 +272,62 @@ class ImmichService implements MediaServiceInterface {
}; };
} }
@override
Future<Map<String, dynamic>> uploadVideo(File videoFile) async {
// Immich supports video uploads - use the same upload endpoint
// The API will detect the file type from the Content-Type header
try {
if (!await videoFile.exists()) {
throw ImmichException('Video file does not exist: ${videoFile.path}');
}
final fileName = videoFile.path.split('/').last;
final fileStat = await videoFile.stat();
final fileCreatedAt = fileStat.changed;
final fileModifiedAt = fileStat.modified;
// Create multipart form data
final formData = FormData.fromMap({
'assetData': await MultipartFile.fromFile(
videoFile.path,
filename: fileName,
contentType: DioMediaType.parse('video/mp4'),
),
'deviceAssetId': fileName,
'deviceId': 'flutter-app',
'fileCreatedAt': fileCreatedAt.toIso8601String(),
'fileModifiedAt': fileModifiedAt.toIso8601String(),
'isFavorite': 'false',
});
final response = await _dio.post(
'/asset/upload',
data: formData,
);
if (response.statusCode != 200 && response.statusCode != 201) {
throw ImmichException(
'Upload failed: ${response.statusMessage}',
response.statusCode,
);
}
final uploadResponse = UploadResponse.fromJson(response.data);
Logger.info('Video uploaded to Immich: ${uploadResponse.id}');
return {
'id': uploadResponse.id,
'url': getImageUrl(uploadResponse.id), // Immich uses same URL pattern for videos
};
} catch (e) {
Logger.error('Immich video upload error: $e');
if (e is ImmichException) {
rethrow;
}
throw ImmichException('Video upload failed: $e');
}
}
/// Fetches a list of assets from Immich. /// Fetches a list of assets from Immich.
/// ///
/// Based on official Immich API documentation: https://api.immich.app/endpoints/search/searchAssets /// Based on official Immich API documentation: https://api.immich.app/endpoints/search/searchAssets

@ -7,6 +7,10 @@ abstract class MediaServiceInterface {
/// Returns a map with 'id' (or 'hash') and 'url' keys. /// Returns a map with 'id' (or 'hash') and 'url' keys.
Future<Map<String, dynamic>> uploadImage(File imageFile); Future<Map<String, dynamic>> uploadImage(File imageFile);
/// Uploads a video file (MP4).
/// Returns a map with 'id' (or 'hash') and 'url' keys.
Future<Map<String, dynamic>> uploadVideo(File videoFile);
/// Fetches image bytes for an asset/blob. /// Fetches image bytes for an asset/blob.
Future<Uint8List> fetchImageBytes(String assetId, {bool isThumbnail = true}); Future<Uint8List> fetchImageBytes(String assetId, {bool isThumbnail = true});

@ -217,6 +217,43 @@ class MultiMediaService implements MediaServiceInterface {
throw lastException ?? Exception('All media servers failed'); throw lastException ?? Exception('All media servers failed');
} }
@override
Future<Map<String, dynamic>> uploadVideo(File videoFile) async {
if (_servers.isEmpty) {
throw Exception('No media servers configured');
}
// Start with default server, then try others
final serversToTry = <MediaServerConfig>[];
if (_defaultServer != null) {
serversToTry.add(_defaultServer!);
}
serversToTry.addAll(_servers.where((s) => s.id != _defaultServer?.id));
Exception? lastException;
for (final config in serversToTry) {
try {
Logger.info('Attempting video upload to ${config.type} server: ${config.baseUrl}');
final service = _createServiceFromConfig(config);
if (service == null) {
Logger.warning('Failed to create service for ${config.id}, trying next...');
continue;
}
final result = await service.uploadVideo(videoFile);
Logger.info('Video upload successful to ${config.type} server: ${config.baseUrl}');
return result;
} catch (e) {
Logger.warning('Video upload failed to ${config.type} server ${config.baseUrl}: $e');
lastException = e is Exception ? e : Exception(e.toString());
// Continue to next server
}
}
// All servers failed
throw lastException ?? Exception('All media servers failed');
}
@override @override
Future<Uint8List> fetchImageBytes(String assetId, {bool isThumbnail = true}) async { Future<Uint8List> fetchImageBytes(String assetId, {bool isThumbnail = true}) async {
if (_servers.isEmpty) { if (_servers.isEmpty) {

@ -20,9 +20,12 @@ class RecipeModel {
/// Whether the recipe is marked as favourite. /// Whether the recipe is marked as favourite.
final bool isFavourite; final bool isFavourite;
/// List of image URLs (from Immich). /// List of image URLs (from Immich/Blossom).
final List<String> imageUrls; final List<String> imageUrls;
/// List of video URLs (from Blossom).
final List<String> videoUrls;
/// Timestamp when recipe was created (milliseconds since epoch). /// Timestamp when recipe was created (milliseconds since epoch).
final int createdAt; final int createdAt;
@ -44,12 +47,14 @@ class RecipeModel {
this.rating = 0, this.rating = 0,
this.isFavourite = false, this.isFavourite = false,
List<String>? imageUrls, List<String>? imageUrls,
List<String>? videoUrls,
int? createdAt, int? createdAt,
int? updatedAt, int? updatedAt,
this.isDeleted = false, this.isDeleted = false,
this.nostrEventId, this.nostrEventId,
}) : tags = tags ?? [], }) : tags = tags ?? [],
imageUrls = imageUrls ?? [], imageUrls = imageUrls ?? [],
videoUrls = videoUrls ?? [],
createdAt = createdAt ?? DateTime.now().millisecondsSinceEpoch, createdAt = createdAt ?? DateTime.now().millisecondsSinceEpoch,
updatedAt = updatedAt ?? DateTime.now().millisecondsSinceEpoch; updatedAt = updatedAt ?? DateTime.now().millisecondsSinceEpoch;
@ -71,6 +76,11 @@ class RecipeModel {
.map((e) => e.toString()) .map((e) => e.toString())
.toList() .toList()
: [], : [],
videoUrls: map['video_urls'] != null
? (jsonDecode(map['video_urls'] as String) as List<dynamic>)
.map((e) => e.toString())
.toList()
: [],
createdAt: map['created_at'] as int, createdAt: map['created_at'] as int,
updatedAt: map['updated_at'] as int, updatedAt: map['updated_at'] as int,
isDeleted: (map['is_deleted'] as int? ?? 0) == 1, isDeleted: (map['is_deleted'] as int? ?? 0) == 1,
@ -88,6 +98,7 @@ class RecipeModel {
'rating': rating, 'rating': rating,
'is_favourite': isFavourite ? 1 : 0, 'is_favourite': isFavourite ? 1 : 0,
'image_urls': jsonEncode(imageUrls), 'image_urls': jsonEncode(imageUrls),
'video_urls': jsonEncode(videoUrls),
'created_at': createdAt, 'created_at': createdAt,
'updated_at': updatedAt, 'updated_at': updatedAt,
'is_deleted': isDeleted ? 1 : 0, 'is_deleted': isDeleted ? 1 : 0,
@ -105,6 +116,7 @@ class RecipeModel {
rating: json['rating'] as int? ?? 0, rating: json['rating'] as int? ?? 0,
isFavourite: json['isFavourite'] as bool? ?? false, isFavourite: json['isFavourite'] as bool? ?? false,
imageUrls: (json['imageUrls'] as List<dynamic>?)?.map((e) => e.toString()).toList() ?? [], imageUrls: (json['imageUrls'] as List<dynamic>?)?.map((e) => e.toString()).toList() ?? [],
videoUrls: (json['videoUrls'] as List<dynamic>?)?.map((e) => e.toString()).toList() ?? [],
createdAt: json['createdAt'] as int? ?? DateTime.now().millisecondsSinceEpoch, createdAt: json['createdAt'] as int? ?? DateTime.now().millisecondsSinceEpoch,
updatedAt: json['updatedAt'] as int? ?? DateTime.now().millisecondsSinceEpoch, updatedAt: json['updatedAt'] as int? ?? DateTime.now().millisecondsSinceEpoch,
isDeleted: json['isDeleted'] as bool? ?? false, isDeleted: json['isDeleted'] as bool? ?? false,
@ -122,6 +134,7 @@ class RecipeModel {
'rating': rating, 'rating': rating,
'isFavourite': isFavourite, 'isFavourite': isFavourite,
'imageUrls': imageUrls, 'imageUrls': imageUrls,
'videoUrls': videoUrls,
'createdAt': createdAt, 'createdAt': createdAt,
'updatedAt': updatedAt, 'updatedAt': updatedAt,
'isDeleted': isDeleted, 'isDeleted': isDeleted,
@ -138,6 +151,7 @@ class RecipeModel {
int? rating, int? rating,
bool? isFavourite, bool? isFavourite,
List<String>? imageUrls, List<String>? imageUrls,
List<String>? videoUrls,
int? createdAt, int? createdAt,
int? updatedAt, int? updatedAt,
bool? isDeleted, bool? isDeleted,
@ -151,6 +165,7 @@ class RecipeModel {
rating: rating ?? this.rating, rating: rating ?? this.rating,
isFavourite: isFavourite ?? this.isFavourite, isFavourite: isFavourite ?? this.isFavourite,
imageUrls: imageUrls ?? this.imageUrls, imageUrls: imageUrls ?? this.imageUrls,
videoUrls: videoUrls ?? this.videoUrls,
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt, updatedAt: updatedAt ?? this.updatedAt,
isDeleted: isDeleted ?? this.isDeleted, isDeleted: isDeleted ?? this.isDeleted,

@ -70,7 +70,7 @@ class RecipeService {
// Open database // Open database
_db = await openDatabase( _db = await openDatabase(
dbPath, dbPath,
version: 2, // Incremented for bookmark tables version: 3, // Incremented for video_urls column
onCreate: _onCreate, onCreate: _onCreate,
onUpgrade: _onUpgrade, onUpgrade: _onUpgrade,
); );
@ -78,11 +78,52 @@ class RecipeService {
// Store the current database path // Store the current database path
_currentDbPath = dbPath; _currentDbPath = dbPath;
// Verify database version and schema after opening
await _verifyDatabaseSchema();
Logger.info('RecipeService initialized with database: $dbPath'); Logger.info('RecipeService initialized with database: $dbPath');
// Log the database path for debugging // Log the database path for debugging
Logger.debug('Current RecipeService database path: $dbPath'); Logger.debug('Current RecipeService database path: $dbPath');
} }
/// Verifies that the database schema is up to date and migrates if needed.
/// This is called after database initialization and can be called before operations
/// to ensure the schema is always up to date.
Future<void> _verifyDatabaseSchema() async {
if (_db == null) return;
try {
// Check if video_urls column exists
final tableInfo = await _db!.rawQuery('PRAGMA table_info(recipes)');
final hasVideoUrls = tableInfo.any((column) => column['name'] == 'video_urls');
if (!hasVideoUrls) {
Logger.warning('video_urls column missing, attempting to add it...');
try {
await _db!.execute('''
ALTER TABLE recipes ADD COLUMN video_urls TEXT NOT NULL DEFAULT '[]'
''');
Logger.info('Successfully added video_urls column to recipes table');
} catch (e) {
Logger.error('Failed to add video_urls column: $e');
// Re-throw to prevent silent failures
rethrow;
}
} else {
Logger.debug('video_urls column exists in recipes table');
}
} catch (e) {
Logger.error('Error verifying database schema: $e');
rethrow;
}
}
/// Ensures the database schema is verified before operations.
/// This is a lightweight check that runs before critical operations.
Future<void> _ensureSchemaUpToDate() async {
await _verifyDatabaseSchema();
}
/// Reinitializes the service with a new database path (for session switching). /// Reinitializes the service with a new database path (for session switching).
/// ///
/// [newDbPath] - New database path to use. /// [newDbPath] - New database path to use.
@ -106,6 +147,7 @@ class RecipeService {
rating INTEGER NOT NULL DEFAULT 0, rating INTEGER NOT NULL DEFAULT 0,
is_favourite INTEGER NOT NULL DEFAULT 0, is_favourite INTEGER NOT NULL DEFAULT 0,
image_urls TEXT NOT NULL, image_urls TEXT NOT NULL,
video_urls TEXT NOT NULL,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
is_deleted INTEGER NOT NULL DEFAULT 0, is_deleted INTEGER NOT NULL DEFAULT 0,
@ -182,9 +224,32 @@ class RecipeService {
await db.execute(''' await db.execute('''
CREATE INDEX IF NOT EXISTS idx_recipe_bookmarks_category_id ON recipe_bookmarks(category_id) CREATE INDEX IF NOT EXISTS idx_recipe_bookmarks_category_id ON recipe_bookmarks(category_id)
'''); ''');
Logger.info('Database migrated from version $oldVersion to $newVersion');
} }
if (oldVersion < 3) {
// Migration to version 3: Add video_urls column
Logger.info('Migrating database from version $oldVersion to 3: Adding video_urls column');
try {
// Check if column already exists
final tableInfo = await db.rawQuery('PRAGMA table_info(recipes)');
final hasVideoUrls = tableInfo.any((column) => column['name'] == 'video_urls');
if (!hasVideoUrls) {
await db.execute('''
ALTER TABLE recipes ADD COLUMN video_urls TEXT NOT NULL DEFAULT '[]'
''');
Logger.info('Successfully added video_urls column to recipes table');
} else {
Logger.info('video_urls column already exists, skipping migration');
}
} catch (e) {
Logger.error('Failed to add video_urls column during migration: $e');
// Re-throw to ensure migration failure is visible
rethrow;
}
}
Logger.info('Database migrated from version $oldVersion to $newVersion');
} }
/// Gets the path to the database file. /// Gets the path to the database file.
@ -230,6 +295,8 @@ class RecipeService {
throw Exception('RecipeService not initialized. Call initialize() first.'); throw Exception('RecipeService not initialized. Call initialize() first.');
} }
} }
// Ensure schema is up to date after initialization/reinitialization
await _ensureSchemaUpToDate();
} }
/// Creates a new recipe. /// Creates a new recipe.
@ -242,6 +309,8 @@ class RecipeService {
Future<RecipeModel> createRecipe(RecipeModel recipe) async { Future<RecipeModel> createRecipe(RecipeModel recipe) async {
// Ensure database is initialized, or reinitialize if needed // Ensure database is initialized, or reinitialize if needed
await _ensureInitializedOrReinitialize(); await _ensureInitializedOrReinitialize();
// Ensure schema is up to date before creating
await _ensureSchemaUpToDate();
try { try {
// Try to get NostrKeyPair from SessionService if not already set // Try to get NostrKeyPair from SessionService if not already set
@ -310,6 +379,8 @@ class RecipeService {
/// Throws [Exception] if update fails or recipe doesn't exist. /// Throws [Exception] if update fails or recipe doesn't exist.
Future<RecipeModel> updateRecipe(RecipeModel recipe) async { Future<RecipeModel> updateRecipe(RecipeModel recipe) async {
_ensureInitialized(); _ensureInitialized();
// Ensure schema is up to date before updating
await _ensureSchemaUpToDate();
try { try {
// Try to get NostrKeyPair from SessionService if not already set // Try to get NostrKeyPair from SessionService if not already set
if (_nostrKeyPair == null) { if (_nostrKeyPair == null) {
@ -1105,6 +1176,7 @@ class RecipeService {
// Extract additional metadata from tags // Extract additional metadata from tags
final imageUrls = <String>[]; final imageUrls = <String>[];
final videoUrls = <String>[];
final tags = <String>[]; final tags = <String>[];
final bookmarkCategoryIds = <String>[]; final bookmarkCategoryIds = <String>[];
int rating = 0; int rating = 0;
@ -1116,6 +1188,9 @@ class RecipeService {
case 'image': case 'image':
if (tag.length > 1) imageUrls.add(tag[1]); if (tag.length > 1) imageUrls.add(tag[1]);
break; break;
case 'video':
if (tag.length > 1) videoUrls.add(tag[1]);
break;
case 't': case 't':
if (tag.length > 1) tags.add(tag[1]); if (tag.length > 1) tags.add(tag[1]);
break; break;
@ -1146,6 +1221,7 @@ class RecipeService {
rating: rating > 0 ? rating : (contentMap['rating'] as int? ?? 0), rating: rating > 0 ? rating : (contentMap['rating'] as int? ?? 0),
isFavourite: isFavourite || (contentMap['isFavourite'] as bool? ?? false), isFavourite: isFavourite || (contentMap['isFavourite'] as bool? ?? false),
imageUrls: imageUrls.isNotEmpty ? imageUrls : (contentMap['imageUrls'] as List<dynamic>?)?.map((e) => e.toString()).toList() ?? [], imageUrls: imageUrls.isNotEmpty ? imageUrls : (contentMap['imageUrls'] as List<dynamic>?)?.map((e) => e.toString()).toList() ?? [],
videoUrls: videoUrls.isNotEmpty ? videoUrls : (contentMap['videoUrls'] as List<dynamic>?)?.map((e) => e.toString()).toList() ?? [],
createdAt: contentMap['createdAt'] as int? ?? (event.createdAt * 1000), createdAt: contentMap['createdAt'] as int? ?? (event.createdAt * 1000),
updatedAt: contentMap['updatedAt'] as int? ?? (event.createdAt * 1000), updatedAt: contentMap['updatedAt'] as int? ?? (event.createdAt * 1000),
nostrEventId: event.id, nostrEventId: event.id,
@ -1202,6 +1278,11 @@ class RecipeService {
tags.add(['image', imageUrl]); tags.add(['image', imageUrl]);
} }
// Add video tags
for (final videoUrl in recipe.videoUrls) {
tags.add(['video', videoUrl]);
}
// Add tag tags // Add tag tags
for (final tag in recipe.tags) { for (final tag in recipe.tags) {
tags.add(['t', tag]); tags.add(['t', tag]);
@ -1225,6 +1306,13 @@ class RecipeService {
// Create event content as JSON // Create event content as JSON
final content = recipe.toJson(); final content = recipe.toJson();
// Log video URLs for debugging
if (recipe.videoUrls.isNotEmpty) {
Logger.info('Publishing recipe ${recipe.id} with ${recipe.videoUrls.length} video(s): ${recipe.videoUrls}');
}
Logger.debug('Recipe ${recipe.id} JSON content: ${jsonEncode(content)}');
Logger.debug('Recipe ${recipe.id} event tags: $tags');
// Create and sign the event // Create and sign the event
// Using kind 30000 (NIP-33 parameterized replaceable event) // Using kind 30000 (NIP-33 parameterized replaceable event)

@ -33,9 +33,12 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
bool _isFavourite = false; bool _isFavourite = false;
List<File> _selectedImages = []; List<File> _selectedImages = [];
List<String> _uploadedImageUrls = []; List<String> _uploadedImageUrls = [];
List<File> _selectedVideos = [];
List<String> _uploadedVideoUrls = [];
bool _isUploading = false; bool _isUploading = false;
bool _isSaving = false; bool _isSaving = false;
String? _errorMessage; String? _errorMessage;
RecipeModel? _currentRecipe; // Track current recipe state for immediate UI updates
final ImagePicker _imagePicker = ImagePicker(); final ImagePicker _imagePicker = ImagePicker();
RecipeService? _recipeService; RecipeService? _recipeService;
@ -75,13 +78,18 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
if (widget.recipe == null) return; if (widget.recipe == null) return;
final recipe = widget.recipe!; final recipe = widget.recipe!;
_currentRecipe = recipe; // Store current recipe state
_titleController.text = recipe.title; _titleController.text = recipe.title;
_descriptionController.text = recipe.description ?? ''; _descriptionController.text = recipe.description ?? '';
_tagsController.text = recipe.tags.join(', '); _tagsController.text = recipe.tags.join(', ');
_rating = recipe.rating; _rating = recipe.rating;
_isFavourite = recipe.isFavourite; _isFavourite = recipe.isFavourite;
_uploadedImageUrls = List.from(recipe.imageUrls); _uploadedImageUrls = List.from(recipe.imageUrls);
_uploadedVideoUrls = List.from(recipe.videoUrls);
} }
/// Gets the current recipe (either from widget or _currentRecipe state)
RecipeModel? get _recipe => _currentRecipe ?? widget.recipe;
@override @override
void dispose() { void dispose() {
@ -160,8 +168,82 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
} }
} }
Future<void> _pickVideos() async {
// Show dialog to choose between camera and gallery
final ImageSource? source = await showDialog<ImageSource>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Select Video Source'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.videocam),
title: const Text('Record Video'),
onTap: () => Navigator.of(context).pop(ImageSource.camera),
),
ListTile(
leading: const Icon(Icons.video_library),
title: const Text('Choose from Gallery'),
onTap: () => Navigator.of(context).pop(ImageSource.gallery),
),
],
),
);
},
);
if (source == null) return;
try {
List<XFile> pickedFiles = [];
if (source == ImageSource.camera) {
// Record a single video
final XFile? pickedFile = await _imagePicker.pickVideo(source: source);
if (pickedFile != null) {
pickedFiles = [pickedFile];
}
} else {
// Pick videos from gallery - pickMedia returns a single XFile or null
// For multiple videos, we'll need to call it multiple times or use a different approach
// For now, let's use pickVideo with gallery source which should work
final XFile? pickedFile = await _imagePicker.pickVideo(source: ImageSource.gallery);
if (pickedFile != null) {
pickedFiles = [pickedFile];
}
}
if (pickedFiles.isEmpty) return;
final newVideos = pickedFiles.map((file) => File(file.path)).toList();
setState(() {
_selectedVideos.addAll(newVideos);
_isUploading = true;
});
// Auto-upload videos immediately
await _uploadImages();
} catch (e) {
Logger.error('Failed to pick videos', e);
if (mounted) {
setState(() {
_isUploading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to pick videos: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
Future<void> _uploadImages() async { Future<void> _uploadImages() async {
if (_selectedImages.isEmpty) return; if (_selectedImages.isEmpty && _selectedVideos.isEmpty) return;
final mediaService = ServiceLocator.instance.mediaService; final mediaService = ServiceLocator.instance.mediaService;
if (mediaService == null) { if (mediaService == null) {
@ -182,15 +264,18 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
}); });
try { try {
final List<String> uploadedUrls = []; final List<String> uploadedImageUrls = [];
final List<String> uploadedVideoUrls = [];
final List<File> failedImages = []; final List<File> failedImages = [];
final List<File> failedVideos = [];
// Upload images
for (final imageFile in _selectedImages) { for (final imageFile in _selectedImages) {
try { try {
final uploadResult = await mediaService.uploadImage(imageFile); final uploadResult = await mediaService.uploadImage(imageFile);
// uploadResult contains 'id' or 'hash' and 'url' // uploadResult contains 'id' or 'hash' and 'url'
final imageUrl = uploadResult['url'] as String? ?? mediaService.getImageUrl(uploadResult['id'] as String? ?? uploadResult['hash'] as String? ?? ''); final imageUrl = uploadResult['url'] as String? ?? mediaService.getImageUrl(uploadResult['id'] as String? ?? uploadResult['hash'] as String? ?? '');
uploadedUrls.add(imageUrl); uploadedImageUrls.add(imageUrl);
Logger.info('Image uploaded: $imageUrl'); Logger.info('Image uploaded: $imageUrl');
} catch (e) { } catch (e) {
Logger.warning('Failed to upload image ${imageFile.path}: $e'); Logger.warning('Failed to upload image ${imageFile.path}: $e');
@ -210,26 +295,56 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
} }
} }
// Upload videos
for (final videoFile in _selectedVideos) {
try {
final uploadResult = await mediaService.uploadVideo(videoFile);
// uploadResult contains 'id' or 'hash' and 'url'
final videoUrl = uploadResult['url'] as String? ?? mediaService.getImageUrl(uploadResult['id'] as String? ?? uploadResult['hash'] as String? ?? '');
uploadedVideoUrls.add(videoUrl);
Logger.info('Video uploaded: $videoUrl');
} catch (e) {
Logger.warning('Failed to upload video ${videoFile.path}: $e');
failedVideos.add(videoFile);
// Show user-friendly error message
final errorMessage = _getUploadErrorMessage(e);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to upload video: $errorMessage'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
}
}
}
setState(() { setState(() {
_uploadedImageUrls.addAll(uploadedUrls); _uploadedImageUrls.addAll(uploadedImageUrls);
// Keep only failed images in the selected list for retry _uploadedVideoUrls.addAll(uploadedVideoUrls);
// Keep only failed files in the selected list for retry
_selectedImages = failedImages; _selectedImages = failedImages;
_selectedVideos = failedVideos;
_isUploading = false; _isUploading = false;
}); });
if (mounted) { if (mounted) {
if (uploadedUrls.isNotEmpty && failedImages.isEmpty) { final totalUploaded = uploadedImageUrls.length + uploadedVideoUrls.length;
final totalFailed = failedImages.length + failedVideos.length;
if (totalUploaded > 0 && totalFailed == 0) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('${uploadedUrls.length} image(s) uploaded successfully'), content: Text('$totalUploaded media file(s) uploaded successfully'),
backgroundColor: Colors.green, backgroundColor: Colors.green,
duration: const Duration(seconds: 1), duration: const Duration(seconds: 1),
), ),
); );
} else if (uploadedUrls.isNotEmpty && failedImages.isNotEmpty) { } else if (totalUploaded > 0 && totalFailed > 0) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('${uploadedUrls.length} image(s) uploaded, ${failedImages.length} failed'), content: Text('$totalUploaded file(s) uploaded, $totalFailed failed'),
backgroundColor: Colors.orange, backgroundColor: Colors.orange,
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
@ -304,8 +419,8 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
return; return;
} }
// Upload any pending images first and wait for completion // Upload any pending images and videos first and wait for completion
if (_selectedImages.isNotEmpty) { if (_selectedImages.isNotEmpty || _selectedVideos.isNotEmpty) {
setState(() { setState(() {
_isSaving = true; _isSaving = true;
_errorMessage = null; _errorMessage = null;
@ -314,10 +429,10 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
await _uploadImages(); await _uploadImages();
// Check if upload failed // Check if upload failed
if (_selectedImages.isNotEmpty) { if (_selectedImages.isNotEmpty || _selectedVideos.isNotEmpty) {
setState(() { setState(() {
_isSaving = false; _isSaving = false;
_errorMessage = 'Some images failed to upload. Please try again.'; _errorMessage = 'Some media files failed to upload. Please try again.';
}); });
return; return;
} }
@ -351,6 +466,7 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
rating: _rating, rating: _rating,
isFavourite: _isFavourite, isFavourite: _isFavourite,
imageUrls: _uploadedImageUrls, imageUrls: _uploadedImageUrls,
videoUrls: _uploadedVideoUrls,
); );
if (widget.recipe != null) { if (widget.recipe != null) {
@ -392,6 +508,12 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
}); });
} }
void _removeVideo(int index) {
setState(() {
_uploadedVideoUrls.removeAt(index);
});
}
Future<void> _toggleFavourite() async { Future<void> _toggleFavourite() async {
if (_recipeService == null || widget.recipe == null) return; if (_recipeService == null || widget.recipe == null) return;
@ -428,15 +550,16 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
} }
Future<void> _removeTag(String tag) async { Future<void> _removeTag(String tag) async {
if (_recipeService == null || widget.recipe == null) return; if (_recipeService == null || _recipe == null) return;
try { try {
final updatedTags = List<String>.from(widget.recipe!.tags)..remove(tag); final updatedTags = List<String>.from(_recipe!.tags)..remove(tag);
final updatedRecipe = widget.recipe!.copyWith(tags: updatedTags); final updatedRecipe = _recipe!.copyWith(tags: updatedTags);
await _recipeService!.updateRecipe(updatedRecipe); await _recipeService!.updateRecipe(updatedRecipe);
if (mounted) { if (mounted) {
setState(() { setState(() {
_currentRecipe = updatedRecipe; // Update current recipe state immediately
_tagsController.text = updatedTags.join(', '); _tagsController.text = updatedTags.join(', ');
}); });
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@ -510,12 +633,12 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
} }
Future<void> _addTag(String tag) async { Future<void> _addTag(String tag) async {
if (_recipeService == null || widget.recipe == null) return; if (_recipeService == null || _recipe == null) return;
final trimmedTag = tag.trim(); final trimmedTag = tag.trim();
if (trimmedTag.isEmpty) return; if (trimmedTag.isEmpty) return;
if (widget.recipe!.tags.contains(trimmedTag)) { if (_recipe!.tags.contains(trimmedTag)) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
@ -528,12 +651,13 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
} }
try { try {
final updatedTags = List<String>.from(widget.recipe!.tags)..add(trimmedTag); final updatedTags = List<String>.from(_recipe!.tags)..add(trimmedTag);
final updatedRecipe = widget.recipe!.copyWith(tags: updatedTags); final updatedRecipe = _recipe!.copyWith(tags: updatedTags);
await _recipeService!.updateRecipe(updatedRecipe); await _recipeService!.updateRecipe(updatedRecipe);
if (mounted) { if (mounted) {
setState(() { setState(() {
_currentRecipe = updatedRecipe; // Update current recipe state immediately
_tagsController.text = updatedTags.join(', '); _tagsController.text = updatedTags.join(', ');
}); });
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@ -760,7 +884,7 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
if (_isUploading) if (_isUploading && _selectedImages.isNotEmpty)
const Padding( const Padding(
padding: EdgeInsets.all(8), padding: EdgeInsets.all(8),
child: Row( child: Row(
@ -816,6 +940,123 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Videos section
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Videos',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
if (!widget.viewMode)
ElevatedButton.icon(
onPressed: _isUploading ? null : _pickVideos,
icon: const Icon(Icons.videocam),
label: const Text('Add Videos'),
),
],
),
const SizedBox(height: 8),
if (_isUploading && _selectedVideos.isNotEmpty)
const Padding(
padding: EdgeInsets.all(8),
child: Row(
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 8),
Text('Uploading videos...'),
],
),
),
if (_uploadedVideoUrls.isNotEmpty)
Wrap(
spacing: 8,
runSpacing: 8,
children: _uploadedVideoUrls.asMap().entries.map((entry) {
final index = entry.key;
// Calculate the actual index in the gallery (images first, then videos)
final galleryIndex = _uploadedImageUrls.length + index;
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PhotoGalleryScreen(
imageUrls: _uploadedImageUrls,
videoUrls: _uploadedVideoUrls,
initialIndex: galleryIndex,
),
),
);
},
child: Stack(
children: [
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(8),
color: Colors.black87,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Stack(
alignment: Alignment.center,
children: [
const Icon(Icons.play_circle_filled, size: 40, color: Colors.white70),
Positioned(
bottom: 4,
left: 4,
right: 4,
child: Text(
'MP4',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
],
),
),
),
if (!widget.viewMode)
Positioned(
top: 4,
right: 4,
child: IconButton(
icon: const Icon(Icons.close, size: 20),
color: Colors.red,
onPressed: () => _removeVideo(index),
),
),
],
),
);
}).toList(),
),
],
),
),
),
const SizedBox(height: 16),
// Error message // Error message
if (_errorMessage != null) if (_errorMessage != null)
Container( Container(
@ -952,12 +1193,23 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
} }
Widget _buildViewMode(BuildContext context) { Widget _buildViewMode(BuildContext context) {
final imagesToShow = _uploadedImageUrls.take(3).toList(); // Combine images and videos, show up to 3 media items
final allMedia = <({String url, bool isVideo})>[];
for (final url in _uploadedImageUrls.take(3)) {
allMedia.add((url: url, isVideo: false));
}
final remainingSlots = 3 - allMedia.length;
if (remainingSlots > 0) {
for (final url in _uploadedVideoUrls.take(remainingSlots)) {
allMedia.add((url: url, isVideo: true));
}
}
final mediaToShow = allMedia.take(3).toList();
return Scaffold( return Scaffold(
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [
// App bar with images // App bar with media
SliverAppBar( SliverAppBar(
expandedHeight: 300, expandedHeight: 300,
pinned: true, pinned: true,
@ -981,7 +1233,7 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
), ),
), ),
flexibleSpace: FlexibleSpaceBar( flexibleSpace: FlexibleSpaceBar(
background: imagesToShow.isEmpty background: mediaToShow.isEmpty
? Container( ? Container(
color: Colors.grey[200], color: Colors.grey[200],
child: const Center( child: const Center(
@ -992,7 +1244,7 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
), ),
), ),
) )
: _buildTiledPhotoLayout(imagesToShow), : _buildTiledPhotoLayout(mediaToShow),
), ),
actions: [], actions: [],
), ),
@ -1043,7 +1295,7 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: [ children: [
...widget.recipe!.tags.map((tag) { ...(_recipe?.tags ?? []).map((tag) {
return Chip( return Chip(
label: Text( label: Text(
tag, tag,
@ -1157,51 +1409,81 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
const SizedBox(height: 24), const SizedBox(height: 24),
], ],
// Remaining photos (smaller) // Remaining media (smaller) - images and videos beyond the first 3
if (_uploadedImageUrls.length > 3) ...[ Builder(
Text( builder: (context) {
'More Photos (${_uploadedImageUrls.length - 3})', final remainingImages = _uploadedImageUrls.length > 3
style: Theme.of(context).textTheme.titleMedium?.copyWith( ? _uploadedImageUrls.skip(3).toList()
fontWeight: FontWeight.bold, : <String>[];
), final remainingVideos = _uploadedImageUrls.length >= 3
), ? _uploadedVideoUrls
const SizedBox(height: 12), : (_uploadedImageUrls.length + _uploadedVideoUrls.length > 3
SizedBox( ? _uploadedVideoUrls.skip(3 - _uploadedImageUrls.length).toList()
height: 120, : <String>[]);
child: ListView.builder( final totalRemaining = remainingImages.length + remainingVideos.length;
scrollDirection: Axis.horizontal,
itemCount: _uploadedImageUrls.length - 3, if (totalRemaining == 0) {
itemBuilder: (context, index) { return const SizedBox.shrink();
final actualIndex = index + 3; }
final imageUrl = _uploadedImageUrls[actualIndex];
return GestureDetector( return Column(
onTap: () { crossAxisAlignment: CrossAxisAlignment.start,
Navigator.push( children: [
context, Text(
MaterialPageRoute( 'More Media ($totalRemaining)',
builder: (context) => PhotoGalleryScreen( style: Theme.of(context).textTheme.titleMedium?.copyWith(
imageUrls: _uploadedImageUrls, fontWeight: FontWeight.bold,
initialIndex: actualIndex, ),
),
const SizedBox(height: 12),
SizedBox(
height: 120,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: totalRemaining,
itemBuilder: (context, index) {
final isVideo = index >= remainingImages.length;
final actualIndex = isVideo
? _uploadedImageUrls.length + (index - remainingImages.length)
: 3 + index;
final mediaUrl = isVideo
? remainingVideos[index - remainingImages.length]
: remainingImages[index];
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PhotoGalleryScreen(
imageUrls: _uploadedImageUrls,
videoUrls: _uploadedVideoUrls,
initialIndex: actualIndex,
),
),
);
},
child: Container(
width: 120,
margin: EdgeInsets.only(
right: index < totalRemaining - 1 ? 12 : 0,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: isVideo
? _buildVideoThumbnailForTile(mediaUrl)
: _buildImagePreview(mediaUrl),
),
), ),
), );
); },
},
child: Container(
width: 120,
margin: EdgeInsets.only(
right: index < _uploadedImageUrls.length - 4 ? 12 : 0,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: _buildImagePreview(imageUrl),
),
), ),
); ),
}, const SizedBox(height: 24),
), ],
), );
const SizedBox(height: 24), },
], ),
// Created date // Created date
if (widget.recipe != null) if (widget.recipe != null)
@ -1222,8 +1504,8 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
); );
} }
Widget _buildTiledPhotoLayout(List<String> imagesToShow) { Widget _buildTiledPhotoLayout(List<({String url, bool isVideo})> mediaToShow) {
final imageCount = imagesToShow.length; final mediaCount = mediaToShow.length;
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
@ -1232,17 +1514,17 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
height: constraints.maxHeight, height: constraints.maxHeight,
child: Row( child: Row(
children: [ children: [
if (imageCount == 1) if (mediaCount == 1)
// Single image: full width // Single media: full width
Expanded( Expanded(
child: _buildImageTile(imagesToShow[0], 0, showBorder: false), child: _buildMediaTile(mediaToShow[0], 0, showBorder: false),
) )
else if (imageCount == 2) else if (mediaCount == 2)
// Two images: split 50/50 // Two media: split 50/50
...imagesToShow.asMap().entries.map((entry) { ...mediaToShow.asMap().entries.map((entry) {
final index = entry.key; final index = entry.key;
return Expanded( return Expanded(
child: _buildImageTile( child: _buildMediaTile(
entry.value, entry.value,
index, index,
showBorder: index == 0, showBorder: index == 0,
@ -1250,31 +1532,31 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
); );
}) })
else else
// Three images: one large on left, two stacked on right // Three media: one large on left, two stacked on right
Expanded( Expanded(
flex: 2, flex: 2,
child: _buildImageTile( child: _buildMediaTile(
imagesToShow[0], mediaToShow[0],
0, 0,
showBorder: false, showBorder: false,
), ),
), ),
if (imageCount == 3) ...[ if (mediaCount == 3) ...[
const SizedBox(width: 2), const SizedBox(width: 2),
Expanded( Expanded(
child: Column( child: Column(
children: [ children: [
Expanded( Expanded(
child: _buildImageTile( child: _buildMediaTile(
imagesToShow[1], mediaToShow[1],
1, 1,
showBorder: true, showBorder: true,
), ),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Expanded( Expanded(
child: _buildImageTile( child: _buildMediaTile(
imagesToShow[2], mediaToShow[2],
2, 2,
showBorder: false, showBorder: false,
), ),
@ -1290,7 +1572,12 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
); );
} }
Widget _buildImageTile(String imageUrl, int index, {required bool showBorder}) { Widget _buildMediaTile(({String url, bool isVideo}) media, int index, {required bool showBorder}) {
// Calculate the actual index in the gallery (accounting for images first, then videos)
final actualIndex = media.isVideo
? _uploadedImageUrls.length + _uploadedVideoUrls.indexOf(media.url)
: _uploadedImageUrls.indexOf(media.url);
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
Navigator.push( Navigator.push(
@ -1298,7 +1585,8 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
MaterialPageRoute( MaterialPageRoute(
builder: (context) => PhotoGalleryScreen( builder: (context) => PhotoGalleryScreen(
imageUrls: _uploadedImageUrls, imageUrls: _uploadedImageUrls,
initialIndex: index, videoUrls: _uploadedVideoUrls,
initialIndex: actualIndex,
), ),
), ),
); );
@ -1314,11 +1602,52 @@ class _AddRecipeScreenState extends State<AddRecipeScreen> {
), ),
) )
: null, : null,
child: _buildImagePreviewForTile(imageUrl), child: media.isVideo
? _buildVideoThumbnailForTile(media.url)
: _buildImagePreviewForTile(media.url),
), ),
); );
} }
Widget _buildVideoThumbnailForTile(String videoUrl) {
return Stack(
fit: StackFit.expand,
children: [
Container(
color: Colors.black87,
child: const Center(
child: Icon(
Icons.play_circle_filled,
size: 60,
color: Colors.white70,
),
),
),
Positioned(
bottom: 8,
left: 8,
right: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'MP4',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
),
],
);
}
/// Builds an image preview specifically for tiled layouts (ensures proper fit). /// Builds an image preview specifically for tiled layouts (ensures proper fit).
Widget _buildImagePreviewForTile(String imageUrl) { Widget _buildImagePreviewForTile(String imageUrl) {
final mediaService = ServiceLocator.instance.mediaService; final mediaService = ServiceLocator.instance.mediaService;

@ -366,12 +366,13 @@ class _RecipeCard extends StatelessWidget {
}); });
void _openPhotoGallery(BuildContext context, int initialIndex) { void _openPhotoGallery(BuildContext context, int initialIndex) {
if (recipe.imageUrls.isEmpty) return; if (recipe.imageUrls.isEmpty && recipe.videoUrls.isEmpty) return;
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => PhotoGalleryScreen( builder: (context) => PhotoGalleryScreen(
imageUrls: recipe.imageUrls, imageUrls: recipe.imageUrls,
videoUrls: recipe.videoUrls,
initialIndex: initialIndex, initialIndex: initialIndex,
), ),
), ),
@ -409,36 +410,50 @@ class _RecipeCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
// Thumbnail (80x80) // Thumbnail (80x80)
if (recipe.imageUrls.isNotEmpty) // Get first media (image or video)
GestureDetector( Builder(
onTap: () => _openPhotoGallery(context, 0), builder: (context) {
child: ClipRRect( final hasMedia = recipe.imageUrls.isNotEmpty || recipe.videoUrls.isNotEmpty;
borderRadius: BorderRadius.circular(12), final isFirstVideo = recipe.imageUrls.isEmpty && recipe.videoUrls.isNotEmpty;
child: Container( final firstMediaUrl = recipe.imageUrls.isNotEmpty
? recipe.imageUrls.first
: (recipe.videoUrls.isNotEmpty ? recipe.videoUrls.first : null);
if (hasMedia && firstMediaUrl != null) {
return GestureDetector(
onTap: () => _openPhotoGallery(context, 0),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(12),
),
child: isFirstVideo
? _buildVideoThumbnail(firstMediaUrl)
: _buildRecipeImage(firstMediaUrl),
),
),
);
} else {
return Container(
width: 80, width: 80,
height: 80, height: 80,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey[200], color: Colors.grey[200],
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: _buildRecipeImage(recipe.imageUrls.first), child: Icon(
), Icons.restaurant_menu,
), color: Colors.grey[400],
) size: 32,
else ),
Container( );
width: 80, }
height: 80, },
decoration: BoxDecoration( ),
color: Colors.grey[200],
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.restaurant_menu,
color: Colors.grey[400],
size: 32,
),
),
const SizedBox(width: 16), const SizedBox(width: 16),
// Content section // Content section
Expanded( Expanded(
@ -520,8 +535,18 @@ class _RecipeCard extends StatelessWidget {
} }
Widget _buildFullCard(BuildContext context) { Widget _buildFullCard(BuildContext context) {
// Show up to 3 images // Combine images and videos, show up to 3 media items
final imagesToShow = recipe.imageUrls.take(3).toList(); final allMedia = <({String url, bool isVideo})>[];
for (final url in recipe.imageUrls.take(3)) {
allMedia.add((url: url, isVideo: false));
}
final remainingSlots = 3 - allMedia.length;
if (remainingSlots > 0) {
for (final url in recipe.videoUrls.take(remainingSlots)) {
allMedia.add((url: url, isVideo: true));
}
}
final mediaToShow = allMedia.take(3).toList();
return Card( return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@ -535,13 +560,13 @@ class _RecipeCard extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Photo section with divided layout // Media section with divided layout
if (imagesToShow.isNotEmpty) if (mediaToShow.isNotEmpty)
ClipRRect( ClipRRect(
borderRadius: const BorderRadius.vertical( borderRadius: const BorderRadius.vertical(
top: Radius.circular(16), top: Radius.circular(16),
), ),
child: _buildPhotoLayout(context, imagesToShow), child: _buildPhotoLayout(context, mediaToShow),
), ),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
@ -640,24 +665,24 @@ class _RecipeCard extends StatelessWidget {
); );
} }
Widget _buildPhotoLayout(BuildContext context, List<String> imagesToShow) { Widget _buildPhotoLayout(BuildContext context, List<({String url, bool isVideo})> mediaToShow) {
final imageCount = imagesToShow.length; final mediaCount = mediaToShow.length;
return AspectRatio( return AspectRatio(
aspectRatio: 2 / 1, // More compact than 16/9 (which is ~1.78/1) aspectRatio: 2 / 1, // More compact than 16/9 (which is ~1.78/1)
child: Row( child: Row(
children: [ children: [
if (imageCount == 1) if (mediaCount == 1)
// Single image: full width // Single media: full width
Expanded( Expanded(
child: _buildImageTile(context, imagesToShow[0], 0, showBorder: false), child: _buildMediaTile(context, mediaToShow[0], 0, showBorder: false),
) )
else if (imageCount == 2) else if (mediaCount == 2)
// Two images: split 50/50 // Two media: split 50/50
...imagesToShow.asMap().entries.map((entry) { ...mediaToShow.asMap().entries.map((entry) {
final index = entry.key; final index = entry.key;
return Expanded( return Expanded(
child: _buildImageTile( child: _buildMediaTile(
context, context,
entry.value, entry.value,
index, index,
@ -666,34 +691,34 @@ class _RecipeCard extends StatelessWidget {
); );
}) })
else else
// Three images: one large on left, two stacked on right // Three media: one large on left, two stacked on right
Expanded( Expanded(
flex: 2, flex: 2,
child: _buildImageTile( child: _buildMediaTile(
context, context,
imagesToShow[0], mediaToShow[0],
0, 0,
showBorder: false, showBorder: false,
), ),
), ),
if (imageCount == 3) ...[ if (mediaCount == 3) ...[
const SizedBox(width: 2), const SizedBox(width: 2),
Expanded( Expanded(
child: Column( child: Column(
children: [ children: [
Expanded( Expanded(
child: _buildImageTile( child: _buildMediaTile(
context, context,
imagesToShow[1], mediaToShow[1],
1, 1,
showBorder: true, showBorder: true,
), ),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Expanded( Expanded(
child: _buildImageTile( child: _buildMediaTile(
context, context,
imagesToShow[2], mediaToShow[2],
2, 2,
showBorder: false, showBorder: false,
), ),
@ -707,9 +732,14 @@ class _RecipeCard extends StatelessWidget {
); );
} }
Widget _buildImageTile(BuildContext context, String imageUrl, int index, {required bool showBorder}) { Widget _buildMediaTile(BuildContext context, ({String url, bool isVideo}) media, int index, {required bool showBorder}) {
// Calculate the actual index in the gallery (accounting for images first, then videos)
final actualIndex = media.isVideo
? recipe.imageUrls.length + recipe.videoUrls.indexOf(media.url)
: recipe.imageUrls.indexOf(media.url);
return GestureDetector( return GestureDetector(
onTap: () => _openPhotoGallery(context, index), onTap: () => _openPhotoGallery(context, actualIndex),
child: Container( child: Container(
decoration: showBorder decoration: showBorder
? BoxDecoration( ? BoxDecoration(
@ -721,7 +751,9 @@ class _RecipeCard extends StatelessWidget {
), ),
) )
: null, : null,
child: _buildRecipeImage(imageUrl), child: media.isVideo
? _buildVideoThumbnail(media.url)
: _buildRecipeImage(media.url),
), ),
); );
} }
@ -800,4 +832,43 @@ class _RecipeCard extends StatelessWidget {
}, },
); );
} }
Widget _buildVideoThumbnail(String videoUrl) {
return Stack(
fit: StackFit.expand,
children: [
Container(
color: Colors.black87,
child: const Center(
child: Icon(
Icons.play_circle_filled,
size: 40,
color: Colors.white70,
),
),
),
Positioned(
bottom: 4,
left: 4,
right: 4,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'MP4',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
),
],
);
}
} }

@ -1,16 +1,19 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import '../../core/service_locator.dart'; import '../../core/service_locator.dart';
/// Photo gallery screen for viewing recipe images in full screen. /// Media gallery screen for viewing recipe images and videos in full screen.
/// Supports swiping between images and pinch-to-zoom. /// Supports swiping between media and pinch-to-zoom for images.
class PhotoGalleryScreen extends StatefulWidget { class PhotoGalleryScreen extends StatefulWidget {
final List<String> imageUrls; final List<String> imageUrls;
final List<String> videoUrls;
final int initialIndex; final int initialIndex;
const PhotoGalleryScreen({ const PhotoGalleryScreen({
super.key, super.key,
required this.imageUrls, required this.imageUrls,
this.videoUrls = const [],
this.initialIndex = 0, this.initialIndex = 0,
}); });
@ -21,22 +24,62 @@ class PhotoGalleryScreen extends StatefulWidget {
class _PhotoGalleryScreenState extends State<PhotoGalleryScreen> { class _PhotoGalleryScreenState extends State<PhotoGalleryScreen> {
late PageController _pageController; late PageController _pageController;
late int _currentIndex; late int _currentIndex;
final Map<int, VideoPlayerController> _videoControllers = {};
List<String> get _allMediaUrls {
return [...widget.imageUrls, ...widget.videoUrls];
}
bool _isVideo(int index) {
return index >= widget.imageUrls.length;
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_currentIndex = widget.initialIndex; _currentIndex = widget.initialIndex;
_pageController = PageController(initialPage: widget.initialIndex); _pageController = PageController(initialPage: widget.initialIndex);
_initializeVideo(_currentIndex);
} }
@override @override
void dispose() { void dispose() {
_pageController.dispose(); _pageController.dispose();
for (final controller in _videoControllers.values) {
controller.dispose();
}
super.dispose(); super.dispose();
} }
void _initializeVideo(int index) {
if (_isVideo(index)) {
final videoIndex = index - widget.imageUrls.length;
if (videoIndex >= 0 && videoIndex < widget.videoUrls.length) {
final videoUrl = widget.videoUrls[videoIndex];
if (!_videoControllers.containsKey(index)) {
final controller = VideoPlayerController.networkUrl(Uri.parse(videoUrl));
controller.initialize().then((_) {
if (mounted) {
setState(() {});
controller.play();
}
});
_videoControllers[index] = controller;
}
}
}
}
void _disposeVideo(int index) {
final controller = _videoControllers.remove(index);
controller?.dispose();
}
void _goToPrevious() { void _goToPrevious() {
if (_currentIndex > 0) { if (_currentIndex > 0) {
_disposeVideo(_currentIndex);
_currentIndex--;
_initializeVideo(_currentIndex);
_pageController.previousPage( _pageController.previousPage(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut, curve: Curves.easeInOut,
@ -45,7 +88,10 @@ class _PhotoGalleryScreenState extends State<PhotoGalleryScreen> {
} }
void _goToNext() { void _goToNext() {
if (_currentIndex < widget.imageUrls.length - 1) { if (_currentIndex < _allMediaUrls.length - 1) {
_disposeVideo(_currentIndex);
_currentIndex++;
_initializeVideo(_currentIndex);
_pageController.nextPage( _pageController.nextPage(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut, curve: Curves.easeInOut,
@ -73,34 +119,40 @@ class _PhotoGalleryScreenState extends State<PhotoGalleryScreen> {
), ),
), ),
title: Text( title: Text(
'${_currentIndex + 1} / ${widget.imageUrls.length}', '${_currentIndex + 1} / ${_allMediaUrls.length}',
style: const TextStyle(color: Colors.white), style: const TextStyle(color: Colors.white),
), ),
centerTitle: true, centerTitle: true,
), ),
body: Stack( body: Stack(
children: [ children: [
// Photo viewer // Media viewer
PageView.builder( PageView.builder(
controller: _pageController, controller: _pageController,
itemCount: widget.imageUrls.length, itemCount: _allMediaUrls.length,
onPageChanged: (index) { onPageChanged: (index) {
setState(() { setState(() {
_disposeVideo(_currentIndex);
_currentIndex = index; _currentIndex = index;
_initializeVideo(_currentIndex);
}); });
}, },
itemBuilder: (context, index) { itemBuilder: (context, index) {
return Center( if (_isVideo(index)) {
child: InteractiveViewer( return _buildVideo(index);
minScale: 0.5, } else {
maxScale: 3.0, return Center(
child: _buildImage(widget.imageUrls[index]), child: InteractiveViewer(
), minScale: 0.5,
); maxScale: 3.0,
child: _buildImage(widget.imageUrls[index]),
),
);
}
}, },
), ),
// Navigation arrows // Navigation arrows
if (widget.imageUrls.length > 1) ...[ if (_allMediaUrls.length > 1) ...[
// Previous arrow (left) // Previous arrow (left)
if (_currentIndex > 0) if (_currentIndex > 0)
Positioned( Positioned(
@ -126,7 +178,7 @@ class _PhotoGalleryScreenState extends State<PhotoGalleryScreen> {
), ),
), ),
// Next arrow (right) // Next arrow (right)
if (_currentIndex < widget.imageUrls.length - 1) if (_currentIndex < _allMediaUrls.length - 1)
Positioned( Positioned(
right: 16, right: 16,
top: 0, top: 0,
@ -231,5 +283,24 @@ class _PhotoGalleryScreenState extends State<PhotoGalleryScreen> {
}, },
); );
} }
/// Builds a video player widget.
Widget _buildVideo(int index) {
final controller = _videoControllers[index];
if (controller == null || !controller.value.isInitialized) {
return const Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
);
}
return Center(
child: AspectRatio(
aspectRatio: controller.value.aspectRatio,
child: VideoPlayer(controller),
),
);
}
} }

@ -111,8 +111,11 @@ class _RecipesScreenState extends State<RecipesScreen> with WidgetsBindingObserv
_filteredRecipes = List.from(_recipes); _filteredRecipes = List.from(_recipes);
} else { } else {
_filteredRecipes = _recipes.where((recipe) { _filteredRecipes = _recipes.where((recipe) {
return recipe.title.toLowerCase().contains(query) || // Search in title, description, and tags
(recipe.description?.toLowerCase().contains(query) ?? false); final titleMatch = recipe.title.toLowerCase().contains(query);
final descriptionMatch = recipe.description?.toLowerCase().contains(query) ?? false;
final tagsMatch = recipe.tags.any((tag) => tag.toLowerCase().contains(query));
return titleMatch || descriptionMatch || tagsMatch;
}).toList(); }).toList();
} }
}); });
@ -540,12 +543,13 @@ class _RecipeCard extends StatelessWidget {
} }
void _openPhotoGallery(BuildContext context, int initialIndex) { void _openPhotoGallery(BuildContext context, int initialIndex) {
if (recipe.imageUrls.isEmpty) return; if (recipe.imageUrls.isEmpty && recipe.videoUrls.isEmpty) return;
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => PhotoGalleryScreen( builder: (context) => PhotoGalleryScreen(
imageUrls: recipe.imageUrls, imageUrls: recipe.imageUrls,
videoUrls: recipe.videoUrls,
initialIndex: initialIndex, initialIndex: initialIndex,
), ),
), ),
@ -577,36 +581,50 @@ class _RecipeCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
// Thumbnail (80x80) // Thumbnail (80x80)
if (recipe.imageUrls.isNotEmpty) // Get first media (image or video)
GestureDetector( Builder(
onTap: () => _openPhotoGallery(context, 0), builder: (context) {
child: ClipRRect( final hasMedia = recipe.imageUrls.isNotEmpty || recipe.videoUrls.isNotEmpty;
borderRadius: BorderRadius.circular(12), final isFirstVideo = recipe.imageUrls.isEmpty && recipe.videoUrls.isNotEmpty;
child: Container( final firstMediaUrl = recipe.imageUrls.isNotEmpty
? recipe.imageUrls.first
: (recipe.videoUrls.isNotEmpty ? recipe.videoUrls.first : null);
if (hasMedia && firstMediaUrl != null) {
return GestureDetector(
onTap: () => _openPhotoGallery(context, 0),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(12),
),
child: isFirstVideo
? _buildVideoThumbnail(firstMediaUrl)
: _buildRecipeImage(firstMediaUrl),
),
),
);
} else {
return Container(
width: 80, width: 80,
height: 80, height: 80,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey[200], color: Colors.grey[200],
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: _buildRecipeImage(recipe.imageUrls.first), child: Icon(
), Icons.restaurant_menu,
), color: Colors.grey[400],
) size: 32,
else ),
Container( );
width: 80, }
height: 80, },
decoration: BoxDecoration( ),
color: Colors.grey[200],
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.restaurant_menu,
color: Colors.grey[400],
size: 32,
),
),
const SizedBox(width: 16), const SizedBox(width: 16),
// Content section // Content section
Expanded( Expanded(
@ -716,8 +734,18 @@ class _RecipeCard extends StatelessWidget {
} }
Widget _buildFullCard(BuildContext context) { Widget _buildFullCard(BuildContext context) {
// Show up to 3 images // Combine images and videos, show up to 3 media items
final imagesToShow = recipe.imageUrls.take(3).toList(); final allMedia = <({String url, bool isVideo})>[];
for (final url in recipe.imageUrls.take(3)) {
allMedia.add((url: url, isVideo: false));
}
final remainingSlots = 3 - allMedia.length;
if (remainingSlots > 0) {
for (final url in recipe.videoUrls.take(remainingSlots)) {
allMedia.add((url: url, isVideo: true));
}
}
final mediaToShow = allMedia.take(3).toList();
return Card( return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@ -731,13 +759,13 @@ class _RecipeCard extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Photo section with divided layout // Media section with divided layout
if (imagesToShow.isNotEmpty) if (mediaToShow.isNotEmpty)
ClipRRect( ClipRRect(
borderRadius: const BorderRadius.vertical( borderRadius: const BorderRadius.vertical(
top: Radius.circular(16), top: Radius.circular(16),
), ),
child: _buildPhotoLayout(context, imagesToShow), child: _buildPhotoLayout(context, mediaToShow),
), ),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
@ -858,24 +886,24 @@ class _RecipeCard extends StatelessWidget {
); );
} }
Widget _buildPhotoLayout(BuildContext context, List<String> imagesToShow) { Widget _buildPhotoLayout(BuildContext context, List<({String url, bool isVideo})> mediaToShow) {
final imageCount = imagesToShow.length; final mediaCount = mediaToShow.length;
return AspectRatio( return AspectRatio(
aspectRatio: 2 / 1, // More compact than 16/9 (which is ~1.78/1) aspectRatio: 2 / 1, // More compact than 16/9 (which is ~1.78/1)
child: Row( child: Row(
children: [ children: [
if (imageCount == 1) if (mediaCount == 1)
// Single image: full width // Single media: full width
Expanded( Expanded(
child: _buildImageTile(context, imagesToShow[0], 0, showBorder: false), child: _buildMediaTile(context, mediaToShow[0], 0, showBorder: false),
) )
else if (imageCount == 2) else if (mediaCount == 2)
// Two images: split 50/50 // Two media: split 50/50
...imagesToShow.asMap().entries.map((entry) { ...mediaToShow.asMap().entries.map((entry) {
final index = entry.key; final index = entry.key;
return Expanded( return Expanded(
child: _buildImageTile( child: _buildMediaTile(
context, context,
entry.value, entry.value,
index, index,
@ -884,34 +912,34 @@ class _RecipeCard extends StatelessWidget {
); );
}) })
else else
// Three images: one large on left, two stacked on right // Three media: one large on left, two stacked on right
Expanded( Expanded(
flex: 2, flex: 2,
child: _buildImageTile( child: _buildMediaTile(
context, context,
imagesToShow[0], mediaToShow[0],
0, 0,
showBorder: false, showBorder: false,
), ),
), ),
if (imageCount == 3) ...[ if (mediaCount == 3) ...[
const SizedBox(width: 2), const SizedBox(width: 2),
Expanded( Expanded(
child: Column( child: Column(
children: [ children: [
Expanded( Expanded(
child: _buildImageTile( child: _buildMediaTile(
context, context,
imagesToShow[1], mediaToShow[1],
1, 1,
showBorder: true, showBorder: true,
), ),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Expanded( Expanded(
child: _buildImageTile( child: _buildMediaTile(
context, context,
imagesToShow[2], mediaToShow[2],
2, 2,
showBorder: false, showBorder: false,
), ),
@ -925,9 +953,14 @@ class _RecipeCard extends StatelessWidget {
); );
} }
Widget _buildImageTile(BuildContext context, String imageUrl, int index, {required bool showBorder}) { Widget _buildMediaTile(BuildContext context, ({String url, bool isVideo}) media, int index, {required bool showBorder}) {
// Calculate the actual index in the gallery (accounting for images first, then videos)
final actualIndex = media.isVideo
? recipe.imageUrls.length + recipe.videoUrls.indexOf(media.url)
: recipe.imageUrls.indexOf(media.url);
return GestureDetector( return GestureDetector(
onTap: () => _openPhotoGallery(context, index), onTap: () => _openPhotoGallery(context, actualIndex),
child: Container( child: Container(
decoration: showBorder decoration: showBorder
? BoxDecoration( ? BoxDecoration(
@ -939,7 +972,9 @@ class _RecipeCard extends StatelessWidget {
), ),
) )
: null, : null,
child: _buildRecipeImage(imageUrl), child: media.isVideo
? _buildVideoThumbnail(media.url)
: _buildRecipeImage(media.url),
), ),
); );
} }
@ -1017,6 +1052,45 @@ class _RecipeCard extends StatelessWidget {
); );
} }
Widget _buildVideoThumbnail(String videoUrl) {
return Stack(
fit: StackFit.expand,
children: [
Container(
color: Colors.black87,
child: const Center(
child: Icon(
Icons.play_circle_filled,
size: 40,
color: Colors.white70,
),
),
),
Positioned(
bottom: 4,
left: 4,
right: 4,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'MP4',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
),
],
);
}
Color _getRatingColor(int rating) { Color _getRatingColor(int rating) {
if (rating >= 4) return Colors.green; if (rating >= 4) return Colors.green;
if (rating >= 2) return Colors.orange; if (rating >= 2) return Colors.orange;

@ -14,6 +14,7 @@ import firebase_messaging
import firebase_storage import firebase_storage
import path_provider_foundation import path_provider_foundation
import sqflite_darwin import sqflite_darwin
import video_player_avfoundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin")) FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin"))
@ -25,4 +26,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseStoragePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseStoragePlugin")) FLTFirebaseStoragePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseStoragePlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
} }

@ -265,6 +265,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.7" version: "3.0.7"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
dart_style: dart_style:
dependency: transitive dependency: transitive
description: description:
@ -544,6 +552,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.0" version: "0.2.0"
html:
dependency: transitive
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
http: http:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1085,6 +1101,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
video_player:
dependency: "direct main"
description:
name: video_player
sha256: "096bc28ce10d131be80dfb00c223024eb0fba301315a406728ab43dd99c45bdf"
url: "https://pub.dev"
source: hosted
version: "2.10.1"
video_player_android:
dependency: transitive
description:
name: video_player_android
sha256: cf768d02924b91e333e2bc1ff928528f57d686445874f383bafab12d0bdfc340
url: "https://pub.dev"
source: hosted
version: "2.8.17"
video_player_avfoundation:
dependency: transitive
description:
name: video_player_avfoundation
sha256: "03fc6d07dba2499588d30887329b399c1fe2d68ce4b7fcff0db79f44a2603f69"
url: "https://pub.dev"
source: hosted
version: "2.8.6"
video_player_platform_interface:
dependency: transitive
description:
name: video_player_platform_interface
sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec"
url: "https://pub.dev"
source: hosted
version: "6.6.0"
video_player_web:
dependency: transitive
description:
name: video_player_web
sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
vm_service: vm_service:
dependency: transitive dependency: transitive
description: description:

@ -25,6 +25,7 @@ dependencies:
firebase_messaging: ^15.0.0 firebase_messaging: ^15.0.0
firebase_analytics: ^11.0.0 firebase_analytics: ^11.0.0
image_picker: ^1.0.7 image_picker: ^1.0.7
video_player: ^2.8.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

Loading…
Cancel
Save

Powered by TurnKey Linux.