From 78d729f523cf3ee837d52e94335949450f73cef5 Mon Sep 17 00:00:00 2001 From: gitea Date: Thu, 13 Nov 2025 21:14:44 +0100 Subject: [PATCH] mp4 suppor added --- lib/data/blossom/blossom_service.dart | 109 ++++ lib/data/immich/immich_service.dart | 56 ++ lib/data/media/media_service_interface.dart | 4 + lib/data/media/multi_media_service.dart | 37 ++ lib/data/recipes/models/recipe_model.dart | 17 +- lib/data/recipes/recipe_service.dart | 94 +++- lib/ui/add_recipe/add_recipe_screen.dart | 507 +++++++++++++++--- lib/ui/favourites/favourites_screen.dart | 171 ++++-- .../photo_gallery/photo_gallery_screen.dart | 101 +++- lib/ui/recipes/recipes_screen.dart | 178 ++++-- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 56 ++ pubspec.yaml | 1 + 13 files changed, 1123 insertions(+), 210 deletions(-) diff --git a/lib/data/blossom/blossom_service.dart b/lib/data/blossom/blossom_service.dart index 3c98abd..7a78a45 100644 --- a/lib/data/blossom/blossom_service.dart +++ b/lib/data/blossom/blossom_service.dart @@ -280,6 +280,115 @@ class BlossomService implements MediaServiceInterface { }; } + /// Uploads a video file (implements MediaServiceInterface). + @override + Future> 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; + 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. /// /// [blobHash] - The SHA256 hash of the blob (or full URL). diff --git a/lib/data/immich/immich_service.dart b/lib/data/immich/immich_service.dart index b4a331c..d022551 100644 --- a/lib/data/immich/immich_service.dart +++ b/lib/data/immich/immich_service.dart @@ -272,6 +272,62 @@ class ImmichService implements MediaServiceInterface { }; } + @override + Future> 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. /// /// Based on official Immich API documentation: https://api.immich.app/endpoints/search/searchAssets diff --git a/lib/data/media/media_service_interface.dart b/lib/data/media/media_service_interface.dart index 7c279cf..bac77b6 100644 --- a/lib/data/media/media_service_interface.dart +++ b/lib/data/media/media_service_interface.dart @@ -7,6 +7,10 @@ abstract class MediaServiceInterface { /// Returns a map with 'id' (or 'hash') and 'url' keys. Future> uploadImage(File imageFile); + /// Uploads a video file (MP4). + /// Returns a map with 'id' (or 'hash') and 'url' keys. + Future> uploadVideo(File videoFile); + /// Fetches image bytes for an asset/blob. Future fetchImageBytes(String assetId, {bool isThumbnail = true}); diff --git a/lib/data/media/multi_media_service.dart b/lib/data/media/multi_media_service.dart index e934e41..7d82111 100644 --- a/lib/data/media/multi_media_service.dart +++ b/lib/data/media/multi_media_service.dart @@ -217,6 +217,43 @@ class MultiMediaService implements MediaServiceInterface { throw lastException ?? Exception('All media servers failed'); } + @override + Future> uploadVideo(File videoFile) async { + if (_servers.isEmpty) { + throw Exception('No media servers configured'); + } + + // Start with default server, then try others + final serversToTry = []; + 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 Future fetchImageBytes(String assetId, {bool isThumbnail = true}) async { if (_servers.isEmpty) { diff --git a/lib/data/recipes/models/recipe_model.dart b/lib/data/recipes/models/recipe_model.dart index e45edb2..13a2ee9 100644 --- a/lib/data/recipes/models/recipe_model.dart +++ b/lib/data/recipes/models/recipe_model.dart @@ -20,9 +20,12 @@ class RecipeModel { /// Whether the recipe is marked as favourite. final bool isFavourite; - /// List of image URLs (from Immich). + /// List of image URLs (from Immich/Blossom). final List imageUrls; + /// List of video URLs (from Blossom). + final List videoUrls; + /// Timestamp when recipe was created (milliseconds since epoch). final int createdAt; @@ -44,12 +47,14 @@ class RecipeModel { this.rating = 0, this.isFavourite = false, List? imageUrls, + List? videoUrls, int? createdAt, int? updatedAt, this.isDeleted = false, this.nostrEventId, }) : tags = tags ?? [], imageUrls = imageUrls ?? [], + videoUrls = videoUrls ?? [], createdAt = createdAt ?? DateTime.now().millisecondsSinceEpoch, updatedAt = updatedAt ?? DateTime.now().millisecondsSinceEpoch; @@ -71,6 +76,11 @@ class RecipeModel { .map((e) => e.toString()) .toList() : [], + videoUrls: map['video_urls'] != null + ? (jsonDecode(map['video_urls'] as String) as List) + .map((e) => e.toString()) + .toList() + : [], createdAt: map['created_at'] as int, updatedAt: map['updated_at'] as int, isDeleted: (map['is_deleted'] as int? ?? 0) == 1, @@ -88,6 +98,7 @@ class RecipeModel { 'rating': rating, 'is_favourite': isFavourite ? 1 : 0, 'image_urls': jsonEncode(imageUrls), + 'video_urls': jsonEncode(videoUrls), 'created_at': createdAt, 'updated_at': updatedAt, 'is_deleted': isDeleted ? 1 : 0, @@ -105,6 +116,7 @@ class RecipeModel { rating: json['rating'] as int? ?? 0, isFavourite: json['isFavourite'] as bool? ?? false, imageUrls: (json['imageUrls'] as List?)?.map((e) => e.toString()).toList() ?? [], + videoUrls: (json['videoUrls'] as List?)?.map((e) => e.toString()).toList() ?? [], createdAt: json['createdAt'] as int? ?? DateTime.now().millisecondsSinceEpoch, updatedAt: json['updatedAt'] as int? ?? DateTime.now().millisecondsSinceEpoch, isDeleted: json['isDeleted'] as bool? ?? false, @@ -122,6 +134,7 @@ class RecipeModel { 'rating': rating, 'isFavourite': isFavourite, 'imageUrls': imageUrls, + 'videoUrls': videoUrls, 'createdAt': createdAt, 'updatedAt': updatedAt, 'isDeleted': isDeleted, @@ -138,6 +151,7 @@ class RecipeModel { int? rating, bool? isFavourite, List? imageUrls, + List? videoUrls, int? createdAt, int? updatedAt, bool? isDeleted, @@ -151,6 +165,7 @@ class RecipeModel { rating: rating ?? this.rating, isFavourite: isFavourite ?? this.isFavourite, imageUrls: imageUrls ?? this.imageUrls, + videoUrls: videoUrls ?? this.videoUrls, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, isDeleted: isDeleted ?? this.isDeleted, diff --git a/lib/data/recipes/recipe_service.dart b/lib/data/recipes/recipe_service.dart index f7d8325..74d2278 100644 --- a/lib/data/recipes/recipe_service.dart +++ b/lib/data/recipes/recipe_service.dart @@ -70,7 +70,7 @@ class RecipeService { // Open database _db = await openDatabase( dbPath, - version: 2, // Incremented for bookmark tables + version: 3, // Incremented for video_urls column onCreate: _onCreate, onUpgrade: _onUpgrade, ); @@ -78,11 +78,52 @@ class RecipeService { // Store the current database path _currentDbPath = dbPath; + // Verify database version and schema after opening + await _verifyDatabaseSchema(); + Logger.info('RecipeService initialized with database: $dbPath'); // Log the database path for debugging 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 _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 _ensureSchemaUpToDate() async { + await _verifyDatabaseSchema(); + } + /// Reinitializes the service with a new database path (for session switching). /// /// [newDbPath] - New database path to use. @@ -106,6 +147,7 @@ class RecipeService { rating INTEGER NOT NULL DEFAULT 0, is_favourite INTEGER NOT NULL DEFAULT 0, image_urls TEXT NOT NULL, + video_urls TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, is_deleted INTEGER NOT NULL DEFAULT 0, @@ -182,9 +224,32 @@ class RecipeService { await db.execute(''' 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. @@ -230,6 +295,8 @@ class RecipeService { throw Exception('RecipeService not initialized. Call initialize() first.'); } } + // Ensure schema is up to date after initialization/reinitialization + await _ensureSchemaUpToDate(); } /// Creates a new recipe. @@ -242,6 +309,8 @@ class RecipeService { Future createRecipe(RecipeModel recipe) async { // Ensure database is initialized, or reinitialize if needed await _ensureInitializedOrReinitialize(); + // Ensure schema is up to date before creating + await _ensureSchemaUpToDate(); try { // 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. Future updateRecipe(RecipeModel recipe) async { _ensureInitialized(); + // Ensure schema is up to date before updating + await _ensureSchemaUpToDate(); try { // Try to get NostrKeyPair from SessionService if not already set if (_nostrKeyPair == null) { @@ -1105,6 +1176,7 @@ class RecipeService { // Extract additional metadata from tags final imageUrls = []; + final videoUrls = []; final tags = []; final bookmarkCategoryIds = []; int rating = 0; @@ -1116,6 +1188,9 @@ class RecipeService { case 'image': if (tag.length > 1) imageUrls.add(tag[1]); break; + case 'video': + if (tag.length > 1) videoUrls.add(tag[1]); + break; case 't': if (tag.length > 1) tags.add(tag[1]); break; @@ -1146,6 +1221,7 @@ class RecipeService { rating: rating > 0 ? rating : (contentMap['rating'] as int? ?? 0), isFavourite: isFavourite || (contentMap['isFavourite'] as bool? ?? false), imageUrls: imageUrls.isNotEmpty ? imageUrls : (contentMap['imageUrls'] as List?)?.map((e) => e.toString()).toList() ?? [], + videoUrls: videoUrls.isNotEmpty ? videoUrls : (contentMap['videoUrls'] as List?)?.map((e) => e.toString()).toList() ?? [], createdAt: contentMap['createdAt'] as int? ?? (event.createdAt * 1000), updatedAt: contentMap['updatedAt'] as int? ?? (event.createdAt * 1000), nostrEventId: event.id, @@ -1202,6 +1278,11 @@ class RecipeService { tags.add(['image', imageUrl]); } + // Add video tags + for (final videoUrl in recipe.videoUrls) { + tags.add(['video', videoUrl]); + } + // Add tag tags for (final tag in recipe.tags) { tags.add(['t', tag]); @@ -1225,6 +1306,13 @@ class RecipeService { // Create event content as JSON 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 // Using kind 30000 (NIP-33 parameterized replaceable event) diff --git a/lib/ui/add_recipe/add_recipe_screen.dart b/lib/ui/add_recipe/add_recipe_screen.dart index 77e6d0b..544477a 100644 --- a/lib/ui/add_recipe/add_recipe_screen.dart +++ b/lib/ui/add_recipe/add_recipe_screen.dart @@ -33,9 +33,12 @@ class _AddRecipeScreenState extends State { bool _isFavourite = false; List _selectedImages = []; List _uploadedImageUrls = []; + List _selectedVideos = []; + List _uploadedVideoUrls = []; bool _isUploading = false; bool _isSaving = false; String? _errorMessage; + RecipeModel? _currentRecipe; // Track current recipe state for immediate UI updates final ImagePicker _imagePicker = ImagePicker(); RecipeService? _recipeService; @@ -75,13 +78,18 @@ class _AddRecipeScreenState extends State { if (widget.recipe == null) return; final recipe = widget.recipe!; + _currentRecipe = recipe; // Store current recipe state _titleController.text = recipe.title; _descriptionController.text = recipe.description ?? ''; _tagsController.text = recipe.tags.join(', '); _rating = recipe.rating; _isFavourite = recipe.isFavourite; _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 void dispose() { @@ -160,8 +168,82 @@ class _AddRecipeScreenState extends State { } } + Future _pickVideos() async { + // Show dialog to choose between camera and gallery + final ImageSource? source = await showDialog( + 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 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 _uploadImages() async { - if (_selectedImages.isEmpty) return; + if (_selectedImages.isEmpty && _selectedVideos.isEmpty) return; final mediaService = ServiceLocator.instance.mediaService; if (mediaService == null) { @@ -182,15 +264,18 @@ class _AddRecipeScreenState extends State { }); try { - final List uploadedUrls = []; + final List uploadedImageUrls = []; + final List uploadedVideoUrls = []; final List failedImages = []; + final List failedVideos = []; + // Upload images for (final imageFile in _selectedImages) { try { final uploadResult = await mediaService.uploadImage(imageFile); // uploadResult contains 'id' or 'hash' and 'url' 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'); } catch (e) { Logger.warning('Failed to upload image ${imageFile.path}: $e'); @@ -210,26 +295,56 @@ class _AddRecipeScreenState extends State { } } + // 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(() { - _uploadedImageUrls.addAll(uploadedUrls); - // Keep only failed images in the selected list for retry + _uploadedImageUrls.addAll(uploadedImageUrls); + _uploadedVideoUrls.addAll(uploadedVideoUrls); + // Keep only failed files in the selected list for retry _selectedImages = failedImages; + _selectedVideos = failedVideos; _isUploading = false; }); 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( SnackBar( - content: Text('${uploadedUrls.length} image(s) uploaded successfully'), + content: Text('$totalUploaded media file(s) uploaded successfully'), backgroundColor: Colors.green, duration: const Duration(seconds: 1), ), ); - } else if (uploadedUrls.isNotEmpty && failedImages.isNotEmpty) { + } else if (totalUploaded > 0 && totalFailed > 0) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('${uploadedUrls.length} image(s) uploaded, ${failedImages.length} failed'), + content: Text('$totalUploaded file(s) uploaded, $totalFailed failed'), backgroundColor: Colors.orange, duration: const Duration(seconds: 2), ), @@ -304,8 +419,8 @@ class _AddRecipeScreenState extends State { return; } - // Upload any pending images first and wait for completion - if (_selectedImages.isNotEmpty) { + // Upload any pending images and videos first and wait for completion + if (_selectedImages.isNotEmpty || _selectedVideos.isNotEmpty) { setState(() { _isSaving = true; _errorMessage = null; @@ -314,10 +429,10 @@ class _AddRecipeScreenState extends State { await _uploadImages(); // Check if upload failed - if (_selectedImages.isNotEmpty) { + if (_selectedImages.isNotEmpty || _selectedVideos.isNotEmpty) { setState(() { _isSaving = false; - _errorMessage = 'Some images failed to upload. Please try again.'; + _errorMessage = 'Some media files failed to upload. Please try again.'; }); return; } @@ -351,6 +466,7 @@ class _AddRecipeScreenState extends State { rating: _rating, isFavourite: _isFavourite, imageUrls: _uploadedImageUrls, + videoUrls: _uploadedVideoUrls, ); if (widget.recipe != null) { @@ -392,6 +508,12 @@ class _AddRecipeScreenState extends State { }); } + void _removeVideo(int index) { + setState(() { + _uploadedVideoUrls.removeAt(index); + }); + } + Future _toggleFavourite() async { if (_recipeService == null || widget.recipe == null) return; @@ -428,15 +550,16 @@ class _AddRecipeScreenState extends State { } Future _removeTag(String tag) async { - if (_recipeService == null || widget.recipe == null) return; + if (_recipeService == null || _recipe == null) return; try { - final updatedTags = List.from(widget.recipe!.tags)..remove(tag); - final updatedRecipe = widget.recipe!.copyWith(tags: updatedTags); + final updatedTags = List.from(_recipe!.tags)..remove(tag); + final updatedRecipe = _recipe!.copyWith(tags: updatedTags); await _recipeService!.updateRecipe(updatedRecipe); if (mounted) { setState(() { + _currentRecipe = updatedRecipe; // Update current recipe state immediately _tagsController.text = updatedTags.join(', '); }); ScaffoldMessenger.of(context).showSnackBar( @@ -510,12 +633,12 @@ class _AddRecipeScreenState extends State { } Future _addTag(String tag) async { - if (_recipeService == null || widget.recipe == null) return; + if (_recipeService == null || _recipe == null) return; final trimmedTag = tag.trim(); if (trimmedTag.isEmpty) return; - if (widget.recipe!.tags.contains(trimmedTag)) { + if (_recipe!.tags.contains(trimmedTag)) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -528,12 +651,13 @@ class _AddRecipeScreenState extends State { } try { - final updatedTags = List.from(widget.recipe!.tags)..add(trimmedTag); - final updatedRecipe = widget.recipe!.copyWith(tags: updatedTags); + final updatedTags = List.from(_recipe!.tags)..add(trimmedTag); + final updatedRecipe = _recipe!.copyWith(tags: updatedTags); await _recipeService!.updateRecipe(updatedRecipe); if (mounted) { setState(() { + _currentRecipe = updatedRecipe; // Update current recipe state immediately _tagsController.text = updatedTags.join(', '); }); ScaffoldMessenger.of(context).showSnackBar( @@ -760,7 +884,7 @@ class _AddRecipeScreenState extends State { ], ), const SizedBox(height: 8), - if (_isUploading) + if (_isUploading && _selectedImages.isNotEmpty) const Padding( padding: EdgeInsets.all(8), child: Row( @@ -816,6 +940,123 @@ class _AddRecipeScreenState extends State { ), 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 if (_errorMessage != null) Container( @@ -952,12 +1193,23 @@ class _AddRecipeScreenState extends State { } 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( body: CustomScrollView( slivers: [ - // App bar with images + // App bar with media SliverAppBar( expandedHeight: 300, pinned: true, @@ -981,7 +1233,7 @@ class _AddRecipeScreenState extends State { ), ), flexibleSpace: FlexibleSpaceBar( - background: imagesToShow.isEmpty + background: mediaToShow.isEmpty ? Container( color: Colors.grey[200], child: const Center( @@ -992,7 +1244,7 @@ class _AddRecipeScreenState extends State { ), ), ) - : _buildTiledPhotoLayout(imagesToShow), + : _buildTiledPhotoLayout(mediaToShow), ), actions: [], ), @@ -1043,7 +1295,7 @@ class _AddRecipeScreenState extends State { spacing: 8, runSpacing: 8, children: [ - ...widget.recipe!.tags.map((tag) { + ...(_recipe?.tags ?? []).map((tag) { return Chip( label: Text( tag, @@ -1157,51 +1409,81 @@ class _AddRecipeScreenState extends State { const SizedBox(height: 24), ], - // Remaining photos (smaller) - if (_uploadedImageUrls.length > 3) ...[ - Text( - 'More Photos (${_uploadedImageUrls.length - 3})', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 12), - SizedBox( - height: 120, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: _uploadedImageUrls.length - 3, - itemBuilder: (context, index) { - final actualIndex = index + 3; - final imageUrl = _uploadedImageUrls[actualIndex]; - return GestureDetector( - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => PhotoGalleryScreen( - imageUrls: _uploadedImageUrls, - initialIndex: actualIndex, + // Remaining media (smaller) - images and videos beyond the first 3 + Builder( + builder: (context) { + final remainingImages = _uploadedImageUrls.length > 3 + ? _uploadedImageUrls.skip(3).toList() + : []; + final remainingVideos = _uploadedImageUrls.length >= 3 + ? _uploadedVideoUrls + : (_uploadedImageUrls.length + _uploadedVideoUrls.length > 3 + ? _uploadedVideoUrls.skip(3 - _uploadedImageUrls.length).toList() + : []); + final totalRemaining = remainingImages.length + remainingVideos.length; + + if (totalRemaining == 0) { + return const SizedBox.shrink(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'More Media ($totalRemaining)', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + 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 if (widget.recipe != null) @@ -1222,8 +1504,8 @@ class _AddRecipeScreenState extends State { ); } - Widget _buildTiledPhotoLayout(List imagesToShow) { - final imageCount = imagesToShow.length; + Widget _buildTiledPhotoLayout(List<({String url, bool isVideo})> mediaToShow) { + final mediaCount = mediaToShow.length; return LayoutBuilder( builder: (context, constraints) { @@ -1232,17 +1514,17 @@ class _AddRecipeScreenState extends State { height: constraints.maxHeight, child: Row( children: [ - if (imageCount == 1) - // Single image: full width + if (mediaCount == 1) + // Single media: full width Expanded( - child: _buildImageTile(imagesToShow[0], 0, showBorder: false), + child: _buildMediaTile(mediaToShow[0], 0, showBorder: false), ) - else if (imageCount == 2) - // Two images: split 50/50 - ...imagesToShow.asMap().entries.map((entry) { + else if (mediaCount == 2) + // Two media: split 50/50 + ...mediaToShow.asMap().entries.map((entry) { final index = entry.key; return Expanded( - child: _buildImageTile( + child: _buildMediaTile( entry.value, index, showBorder: index == 0, @@ -1250,31 +1532,31 @@ class _AddRecipeScreenState extends State { ); }) else - // Three images: one large on left, two stacked on right + // Three media: one large on left, two stacked on right Expanded( flex: 2, - child: _buildImageTile( - imagesToShow[0], + child: _buildMediaTile( + mediaToShow[0], 0, showBorder: false, ), ), - if (imageCount == 3) ...[ + if (mediaCount == 3) ...[ const SizedBox(width: 2), Expanded( child: Column( children: [ Expanded( - child: _buildImageTile( - imagesToShow[1], + child: _buildMediaTile( + mediaToShow[1], 1, showBorder: true, ), ), const SizedBox(height: 2), Expanded( - child: _buildImageTile( - imagesToShow[2], + child: _buildMediaTile( + mediaToShow[2], 2, showBorder: false, ), @@ -1290,7 +1572,12 @@ class _AddRecipeScreenState extends State { ); } - 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( onTap: () { Navigator.push( @@ -1298,7 +1585,8 @@ class _AddRecipeScreenState extends State { MaterialPageRoute( builder: (context) => PhotoGalleryScreen( imageUrls: _uploadedImageUrls, - initialIndex: index, + videoUrls: _uploadedVideoUrls, + initialIndex: actualIndex, ), ), ); @@ -1314,11 +1602,52 @@ class _AddRecipeScreenState extends State { ), ) : 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). Widget _buildImagePreviewForTile(String imageUrl) { final mediaService = ServiceLocator.instance.mediaService; diff --git a/lib/ui/favourites/favourites_screen.dart b/lib/ui/favourites/favourites_screen.dart index d885662..84d26ee 100644 --- a/lib/ui/favourites/favourites_screen.dart +++ b/lib/ui/favourites/favourites_screen.dart @@ -366,12 +366,13 @@ class _RecipeCard extends StatelessWidget { }); void _openPhotoGallery(BuildContext context, int initialIndex) { - if (recipe.imageUrls.isEmpty) return; + if (recipe.imageUrls.isEmpty && recipe.videoUrls.isEmpty) return; Navigator.push( context, MaterialPageRoute( builder: (context) => PhotoGalleryScreen( imageUrls: recipe.imageUrls, + videoUrls: recipe.videoUrls, initialIndex: initialIndex, ), ), @@ -409,36 +410,50 @@ class _RecipeCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.center, children: [ // Thumbnail (80x80) - if (recipe.imageUrls.isNotEmpty) - GestureDetector( - onTap: () => _openPhotoGallery(context, 0), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Container( + // Get first media (image or video) + Builder( + builder: (context) { + final hasMedia = recipe.imageUrls.isNotEmpty || recipe.videoUrls.isNotEmpty; + final isFirstVideo = recipe.imageUrls.isEmpty && recipe.videoUrls.isNotEmpty; + 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, height: 80, decoration: BoxDecoration( color: Colors.grey[200], borderRadius: BorderRadius.circular(12), ), - child: _buildRecipeImage(recipe.imageUrls.first), - ), - ), - ) - 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, - ), - ), + child: Icon( + Icons.restaurant_menu, + color: Colors.grey[400], + size: 32, + ), + ); + } + }, + ), const SizedBox(width: 16), // Content section Expanded( @@ -520,8 +535,18 @@ class _RecipeCard extends StatelessWidget { } Widget _buildFullCard(BuildContext context) { - // Show up to 3 images - final imagesToShow = recipe.imageUrls.take(3).toList(); + // Combine images and videos, show up to 3 media items + 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( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), @@ -535,13 +560,13 @@ class _RecipeCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Photo section with divided layout - if (imagesToShow.isNotEmpty) + // Media section with divided layout + if (mediaToShow.isNotEmpty) ClipRRect( borderRadius: const BorderRadius.vertical( top: Radius.circular(16), ), - child: _buildPhotoLayout(context, imagesToShow), + child: _buildPhotoLayout(context, mediaToShow), ), Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), @@ -640,24 +665,24 @@ class _RecipeCard extends StatelessWidget { ); } - Widget _buildPhotoLayout(BuildContext context, List imagesToShow) { - final imageCount = imagesToShow.length; + Widget _buildPhotoLayout(BuildContext context, List<({String url, bool isVideo})> mediaToShow) { + final mediaCount = mediaToShow.length; return AspectRatio( aspectRatio: 2 / 1, // More compact than 16/9 (which is ~1.78/1) child: Row( children: [ - if (imageCount == 1) - // Single image: full width + if (mediaCount == 1) + // Single media: full width Expanded( - child: _buildImageTile(context, imagesToShow[0], 0, showBorder: false), + child: _buildMediaTile(context, mediaToShow[0], 0, showBorder: false), ) - else if (imageCount == 2) - // Two images: split 50/50 - ...imagesToShow.asMap().entries.map((entry) { + else if (mediaCount == 2) + // Two media: split 50/50 + ...mediaToShow.asMap().entries.map((entry) { final index = entry.key; return Expanded( - child: _buildImageTile( + child: _buildMediaTile( context, entry.value, index, @@ -666,34 +691,34 @@ class _RecipeCard extends StatelessWidget { ); }) else - // Three images: one large on left, two stacked on right + // Three media: one large on left, two stacked on right Expanded( flex: 2, - child: _buildImageTile( + child: _buildMediaTile( context, - imagesToShow[0], + mediaToShow[0], 0, showBorder: false, ), ), - if (imageCount == 3) ...[ + if (mediaCount == 3) ...[ const SizedBox(width: 2), Expanded( child: Column( children: [ Expanded( - child: _buildImageTile( + child: _buildMediaTile( context, - imagesToShow[1], + mediaToShow[1], 1, showBorder: true, ), ), const SizedBox(height: 2), Expanded( - child: _buildImageTile( + child: _buildMediaTile( context, - imagesToShow[2], + mediaToShow[2], 2, 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( - onTap: () => _openPhotoGallery(context, index), + onTap: () => _openPhotoGallery(context, actualIndex), child: Container( decoration: showBorder ? BoxDecoration( @@ -721,7 +751,9 @@ class _RecipeCard extends StatelessWidget { ), ) : 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, + ), + ), + ), + ], + ); + } } diff --git a/lib/ui/photo_gallery/photo_gallery_screen.dart b/lib/ui/photo_gallery/photo_gallery_screen.dart index 43389be..4098c98 100644 --- a/lib/ui/photo_gallery/photo_gallery_screen.dart +++ b/lib/ui/photo_gallery/photo_gallery_screen.dart @@ -1,16 +1,19 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; import '../../core/service_locator.dart'; -/// Photo gallery screen for viewing recipe images in full screen. -/// Supports swiping between images and pinch-to-zoom. +/// Media gallery screen for viewing recipe images and videos in full screen. +/// Supports swiping between media and pinch-to-zoom for images. class PhotoGalleryScreen extends StatefulWidget { final List imageUrls; + final List videoUrls; final int initialIndex; const PhotoGalleryScreen({ super.key, required this.imageUrls, + this.videoUrls = const [], this.initialIndex = 0, }); @@ -21,22 +24,62 @@ class PhotoGalleryScreen extends StatefulWidget { class _PhotoGalleryScreenState extends State { late PageController _pageController; late int _currentIndex; + final Map _videoControllers = {}; + + List get _allMediaUrls { + return [...widget.imageUrls, ...widget.videoUrls]; + } + + bool _isVideo(int index) { + return index >= widget.imageUrls.length; + } @override void initState() { super.initState(); _currentIndex = widget.initialIndex; _pageController = PageController(initialPage: widget.initialIndex); + _initializeVideo(_currentIndex); } @override void dispose() { _pageController.dispose(); + for (final controller in _videoControllers.values) { + controller.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() { if (_currentIndex > 0) { + _disposeVideo(_currentIndex); + _currentIndex--; + _initializeVideo(_currentIndex); _pageController.previousPage( duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, @@ -45,7 +88,10 @@ class _PhotoGalleryScreenState extends State { } void _goToNext() { - if (_currentIndex < widget.imageUrls.length - 1) { + if (_currentIndex < _allMediaUrls.length - 1) { + _disposeVideo(_currentIndex); + _currentIndex++; + _initializeVideo(_currentIndex); _pageController.nextPage( duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, @@ -73,34 +119,40 @@ class _PhotoGalleryScreenState extends State { ), ), title: Text( - '${_currentIndex + 1} / ${widget.imageUrls.length}', + '${_currentIndex + 1} / ${_allMediaUrls.length}', style: const TextStyle(color: Colors.white), ), centerTitle: true, ), body: Stack( children: [ - // Photo viewer + // Media viewer PageView.builder( controller: _pageController, - itemCount: widget.imageUrls.length, + itemCount: _allMediaUrls.length, onPageChanged: (index) { setState(() { + _disposeVideo(_currentIndex); _currentIndex = index; + _initializeVideo(_currentIndex); }); }, itemBuilder: (context, index) { - return Center( - child: InteractiveViewer( - minScale: 0.5, - maxScale: 3.0, - child: _buildImage(widget.imageUrls[index]), - ), - ); + if (_isVideo(index)) { + return _buildVideo(index); + } else { + return Center( + child: InteractiveViewer( + minScale: 0.5, + maxScale: 3.0, + child: _buildImage(widget.imageUrls[index]), + ), + ); + } }, ), // Navigation arrows - if (widget.imageUrls.length > 1) ...[ + if (_allMediaUrls.length > 1) ...[ // Previous arrow (left) if (_currentIndex > 0) Positioned( @@ -126,7 +178,7 @@ class _PhotoGalleryScreenState extends State { ), ), // Next arrow (right) - if (_currentIndex < widget.imageUrls.length - 1) + if (_currentIndex < _allMediaUrls.length - 1) Positioned( right: 16, top: 0, @@ -231,5 +283,24 @@ class _PhotoGalleryScreenState extends State { }, ); } + + /// 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(Colors.white), + ), + ); + } + + return Center( + child: AspectRatio( + aspectRatio: controller.value.aspectRatio, + child: VideoPlayer(controller), + ), + ); + } } diff --git a/lib/ui/recipes/recipes_screen.dart b/lib/ui/recipes/recipes_screen.dart index 18873a6..9eea666 100644 --- a/lib/ui/recipes/recipes_screen.dart +++ b/lib/ui/recipes/recipes_screen.dart @@ -111,8 +111,11 @@ class _RecipesScreenState extends State with WidgetsBindingObserv _filteredRecipes = List.from(_recipes); } else { _filteredRecipes = _recipes.where((recipe) { - return recipe.title.toLowerCase().contains(query) || - (recipe.description?.toLowerCase().contains(query) ?? false); + // Search in title, description, and tags + 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(); } }); @@ -540,12 +543,13 @@ class _RecipeCard extends StatelessWidget { } void _openPhotoGallery(BuildContext context, int initialIndex) { - if (recipe.imageUrls.isEmpty) return; + if (recipe.imageUrls.isEmpty && recipe.videoUrls.isEmpty) return; Navigator.push( context, MaterialPageRoute( builder: (context) => PhotoGalleryScreen( imageUrls: recipe.imageUrls, + videoUrls: recipe.videoUrls, initialIndex: initialIndex, ), ), @@ -577,36 +581,50 @@ class _RecipeCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.center, children: [ // Thumbnail (80x80) - if (recipe.imageUrls.isNotEmpty) - GestureDetector( - onTap: () => _openPhotoGallery(context, 0), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Container( + // Get first media (image or video) + Builder( + builder: (context) { + final hasMedia = recipe.imageUrls.isNotEmpty || recipe.videoUrls.isNotEmpty; + final isFirstVideo = recipe.imageUrls.isEmpty && recipe.videoUrls.isNotEmpty; + 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, height: 80, decoration: BoxDecoration( color: Colors.grey[200], borderRadius: BorderRadius.circular(12), ), - child: _buildRecipeImage(recipe.imageUrls.first), - ), - ), - ) - 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, - ), - ), + child: Icon( + Icons.restaurant_menu, + color: Colors.grey[400], + size: 32, + ), + ); + } + }, + ), const SizedBox(width: 16), // Content section Expanded( @@ -716,8 +734,18 @@ class _RecipeCard extends StatelessWidget { } Widget _buildFullCard(BuildContext context) { - // Show up to 3 images - final imagesToShow = recipe.imageUrls.take(3).toList(); + // Combine images and videos, show up to 3 media items + 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( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), @@ -731,13 +759,13 @@ class _RecipeCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Photo section with divided layout - if (imagesToShow.isNotEmpty) + // Media section with divided layout + if (mediaToShow.isNotEmpty) ClipRRect( borderRadius: const BorderRadius.vertical( top: Radius.circular(16), ), - child: _buildPhotoLayout(context, imagesToShow), + child: _buildPhotoLayout(context, mediaToShow), ), Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), @@ -858,24 +886,24 @@ class _RecipeCard extends StatelessWidget { ); } - Widget _buildPhotoLayout(BuildContext context, List imagesToShow) { - final imageCount = imagesToShow.length; + Widget _buildPhotoLayout(BuildContext context, List<({String url, bool isVideo})> mediaToShow) { + final mediaCount = mediaToShow.length; return AspectRatio( aspectRatio: 2 / 1, // More compact than 16/9 (which is ~1.78/1) child: Row( children: [ - if (imageCount == 1) - // Single image: full width + if (mediaCount == 1) + // Single media: full width Expanded( - child: _buildImageTile(context, imagesToShow[0], 0, showBorder: false), + child: _buildMediaTile(context, mediaToShow[0], 0, showBorder: false), ) - else if (imageCount == 2) - // Two images: split 50/50 - ...imagesToShow.asMap().entries.map((entry) { + else if (mediaCount == 2) + // Two media: split 50/50 + ...mediaToShow.asMap().entries.map((entry) { final index = entry.key; return Expanded( - child: _buildImageTile( + child: _buildMediaTile( context, entry.value, index, @@ -884,34 +912,34 @@ class _RecipeCard extends StatelessWidget { ); }) else - // Three images: one large on left, two stacked on right + // Three media: one large on left, two stacked on right Expanded( flex: 2, - child: _buildImageTile( + child: _buildMediaTile( context, - imagesToShow[0], + mediaToShow[0], 0, showBorder: false, ), ), - if (imageCount == 3) ...[ + if (mediaCount == 3) ...[ const SizedBox(width: 2), Expanded( child: Column( children: [ Expanded( - child: _buildImageTile( + child: _buildMediaTile( context, - imagesToShow[1], + mediaToShow[1], 1, showBorder: true, ), ), const SizedBox(height: 2), Expanded( - child: _buildImageTile( + child: _buildMediaTile( context, - imagesToShow[2], + mediaToShow[2], 2, 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( - onTap: () => _openPhotoGallery(context, index), + onTap: () => _openPhotoGallery(context, actualIndex), child: Container( decoration: showBorder ? BoxDecoration( @@ -939,7 +972,9 @@ class _RecipeCard extends StatelessWidget { ), ) : 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) { if (rating >= 4) return Colors.green; if (rating >= 2) return Colors.orange; diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index b16bc23..468c263 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -14,6 +14,7 @@ import firebase_messaging import firebase_storage import path_provider_foundation import sqflite_darwin +import video_player_avfoundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin")) @@ -25,4 +26,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseStoragePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseStoragePlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 3496b1e..daf5c32 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -265,6 +265,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" dart_style: dependency: transitive description: @@ -544,6 +552,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" http: dependency: "direct main" description: @@ -1085,6 +1101,46 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 2b11b3e..160c8a6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -25,6 +25,7 @@ dependencies: firebase_messaging: ^15.0.0 firebase_analytics: ^11.0.0 image_picker: ^1.0.7 + video_player: ^2.8.2 dev_dependencies: flutter_test: