Building Recipe Sharing Apps with Flutter and Firebase

In today’s digital age, mobile apps have transformed the way people discover, share, and engage with recipes. Whether you’re an aspiring developer, a food enthusiast, or a seasoned programmer, building a recipe sharing app with Flutter and Firebase is an exciting and rewarding project. Flutter, Google’s UI toolkit, empowers developers to create natively compiled applications for mobile, web, and desktop from a single codebase. Combined with Firebase, Google’s comprehensive platform for building web and mobile applications, you have a robust foundation for creating a feature-rich recipe sharing app.

Why Flutter and Firebase?

  • Flutter: Provides a fast development cycle, expressive UI, and native performance.
  • Firebase: Offers a suite of tools including authentication, databases (Firestore), storage, and hosting, which significantly simplifies backend development.

Project Overview

Our recipe sharing app will include the following core features:

  • User Authentication (Sign-up, Sign-in, Sign-out)
  • Recipe Creation (Title, Ingredients, Instructions, Image Upload)
  • Recipe Listing (Display recipes in a feed)
  • Recipe Details (View complete recipe information)
  • Search Functionality (Search recipes by title or ingredients)

Step-by-Step Implementation Guide

Step 1: Setting Up Flutter and Firebase

Before diving into the code, ensure you have Flutter installed and set up correctly. Next, create a Firebase project and configure it for your Flutter app.

1. Install Flutter

Follow the official Flutter installation guide: https://flutter.dev/docs/get-started/install

2. Create a New Flutter Project

Open your terminal and run:

flutter create recipe_sharing_app
cd recipe_sharing_app
3. Set Up Firebase Project
  • Go to the Firebase Console: https://console.firebase.google.com/
  • Click on “Add project” and follow the steps to create a new project.
  • Register your Flutter app with Firebase by selecting the Android and/or iOS option.
  • Download the google-services.json (for Android) and/or GoogleService-Info.plist (for iOS) and add them to your project as instructed.
4. Add Firebase Dependencies

Add the necessary Firebase dependencies to your pubspec.yaml file:

dependencies:
  flutter:
    sdk: flutter
  firebase_core: ^2.15.0
  firebase_auth: ^4.6.0
  cloud_firestore: ^4.9.0
  firebase_storage: ^11.2.2
  image_picker: ^0.8.5
  
dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^2.0.0

Run flutter pub get to fetch the dependencies.

5. Configure Firebase in Your Flutter App

Initialize Firebase in your main.dart file:

import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Recipe Sharing App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: HomeScreen(), // You'll create this later
    );
  }
}

Make sure you have the firebase_options.dart file. If it doesn’t exist, use the FlutterFire CLI to generate it.

Step 2: Implementing User Authentication

Implement user authentication using Firebase Authentication. This will allow users to create accounts, sign in, and sign out.

1. Create Authentication Services

Create an auth_service.dart file:

import 'package:firebase_auth/firebase_auth.dart';

class AuthService {
  final FirebaseAuth _auth = FirebaseAuth.instance;

  // Sign Up
  Future<UserCredential?> signUp(String email, String password) async {
    try {
      final credential = await _auth.createUserWithEmailAndPassword(
          email: email,
          password: password
      );
      return credential;
    } on FirebaseAuthException catch (e) {
      print("Error during sign up: \$e");
      return null;
    }
  }

  // Sign In
  Future<UserCredential?> signIn(String email, String password) async {
    try {
      final credential = await _auth.signInWithEmailAndPassword(
          email: email,
          password: password
      );
      return credential;
    } on FirebaseAuthException catch (e) {
      print("Error during sign in: \$e");
      return null;
    }
  }

  // Sign Out
  Future<void> signOut() async {
    await _auth.signOut();
  }
}
2. Build Sign-Up and Sign-In UI

Create UI components for signing up and signing in:

import 'package:flutter/material.dart';
import 'auth_service.dart';

class SignUpScreen extends StatefulWidget {
  @override
  _SignUpScreenState createState() => _SignUpScreenState();
}

class _SignUpScreenState extends State<SignUpScreen> {
  final AuthService _authService = AuthService();
  final TextEditingController _emailController = TextEditingController();
  final TextEditingController _passwordController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Sign Up')),
      body: Padding(
        padding: EdgeInsets.all(16.0),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            TextField(
              controller: _emailController,
              decoration: InputDecoration(labelText: 'Email'),
            ),
            TextField(
              controller: _passwordController,
              decoration: InputDecoration(labelText: 'Password'),
              obscureText: true,
            ),
            SizedBox(height: 20),
            ElevatedButton(
              onPressed: () async {
                final user = await _authService.signUp(
                    _emailController.text, _passwordController.text);
                if (user != null) {
                  // Navigate to the next screen or show success message
                  print('Sign up successful: \${user.user?.email}');
                } else {
                  // Show error message
                  print('Sign up failed');
                }
              },
              child: Text('Sign Up'),
            ),
          ],
        ),
      ),
    );
  }
}

class SignInScreen extends StatefulWidget {
  @override
  _SignInScreenState createState() => _SignInScreenState();
}

class _SignInScreenState extends State<SignInScreen> {
  final AuthService _authService = AuthService();
  final TextEditingController _emailController = TextEditingController();
  final TextEditingController _passwordController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Sign In')),
      body: Padding(
        padding: EdgeInsets.all(16.0),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            TextField(
              controller: _emailController,
              decoration: InputDecoration(labelText: 'Email'),
            ),
            TextField(
              controller: _passwordController,
              decoration: InputDecoration(labelText: 'Password'),
              obscureText: true,
            ),
            SizedBox(height: 20),
            ElevatedButton(
              onPressed: () async {
                final user = await _authService.signIn(
                    _emailController.text, _passwordController.text);
                if (user != null) {
                  // Navigate to the next screen or show success message
                  print('Sign in successful: \${user.user?.email}');
                } else {
                  // Show error message
                  print('Sign in failed');
                }
              },
              child: Text('Sign In'),
            ),
          ],
        ),
      ),
    );
  }
}

Step 3: Creating Recipes

Implement the functionality for users to create and upload recipes.

1. Data Model

Define a data model for recipes:

class Recipe {
  String? id;
  String? title;
  String? ingredients;
  String? instructions;
  String? imageUrl;
  String? userId;

  Recipe({
    this.id,
    this.title,
    this.ingredients,
    this.instructions,
    this.imageUrl,
    this.userId
  });

  // Convert a Recipe into a Map
  Map<String, dynamic> toJson() => {
    'title': title,
    'ingredients': ingredients,
    'instructions': instructions,
    'imageUrl': imageUrl,
    'userId': userId,
  };

  // Create a Recipe from a Map
  factory Recipe.fromJson(Map<String, dynamic> json, String id) => Recipe(
    id: id,
    title: json['title'],
    ingredients: json['ingredients'],
    instructions: json['instructions'],
    imageUrl: json['imageUrl'],
    userId: json['userId'],
  );
}
2. Recipe Service

Create a service for handling recipe creation and storage:

import 'dart:io';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_storage/firebase_storage.dart';
import 'recipe_model.dart';
import 'package:image_picker/image_picker.dart';

class RecipeService {
  final FirebaseFirestore _firestore = FirebaseFirestore.instance;
  final FirebaseStorage _storage = FirebaseStorage.instance;
  final ImagePicker _picker = ImagePicker();

  // Upload Recipe
  Future<void> uploadRecipe(Recipe recipe, File? imageFile) async {
    try {
      String imageUrl = '';

      if (imageFile != null) {
        imageUrl = await _uploadImage(imageFile);
      }

      // Create a map to store the data in Firestore
      final recipeData = recipe.toJson();
      recipeData['imageUrl'] = imageUrl;  // Add imageUrl to the recipe data

      // Add the recipe data to Firestore
      await _firestore.collection('recipes').add(recipeData);
    } catch (e) {
      print("Error uploading recipe: \$e");
      rethrow;
    }
  }

  // Helper function to upload the image and return the URL
  Future<String> _uploadImage(File imageFile) async {
    String fileName = DateTime.now().millisecondsSinceEpoch.toString();
    Reference storageRef = _storage.ref().child('recipes/\$fileName');
    UploadTask uploadTask = storageRef.putFile(imageFile);
    TaskSnapshot snapshot = await uploadTask;
    String downloadURL = await snapshot.ref.getDownloadURL();
    return downloadURL;
  }

  Future<File?> pickImage() async {
      final XFile? image = await _picker.pickImage(source: ImageSource.gallery);
      if (image != null) {
          return File(image.path);
      }
      return null;
  }
}
3. Recipe Upload UI

Create the UI for uploading recipes, allowing users to add titles, ingredients, instructions, and upload images:

import 'dart:io';
import 'package:flutter/material.dart';
import 'recipe_model.dart';
import 'recipe_service.dart';

class RecipeUploadScreen extends StatefulWidget {
  final String userId;
  RecipeUploadScreen({Key? key, required this.userId}) : super(key: key);

  @override
  _RecipeUploadScreenState createState() => _RecipeUploadScreenState();
}

class _RecipeUploadScreenState extends State<RecipeUploadScreen> {
  final RecipeService _recipeService = RecipeService();
  final TextEditingController _titleController = TextEditingController();
  final TextEditingController _ingredientsController = TextEditingController();
  final TextEditingController _instructionsController = TextEditingController();

  File? _image;

  Future<void> _pickImage() async {
    final pickedFile = await _recipeService.pickImage();

    setState(() {
      if (pickedFile != null) {
        _image = File(pickedFile.path);
      } else {
        print('No image selected.');
      }
    });
  }


  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Upload Recipe')),
      body: SingleChildScrollView(
        padding: EdgeInsets.all(16.0),
        child: Column(
          children: [
            TextField(
              controller: _titleController,
              decoration: InputDecoration(labelText: 'Title'),
            ),
            TextField(
              controller: _ingredientsController,
              decoration: InputDecoration(labelText: 'Ingredients'),
              maxLines: 3,
            ),
            TextField(
              controller: _instructionsController,
              decoration: InputDecoration(labelText: 'Instructions'),
              maxLines: 5,
            ),
            SizedBox(height: 20),
            _image == null
                ? ElevatedButton(
              onPressed: _pickImage,
              child: Text('Select Image'),
            )
                : Image.file(
              _image!,
              height: 100,
            ),
            SizedBox(height: 20),
            ElevatedButton(
              onPressed: () async {
                final recipe = Recipe(
                  title: _titleController.text,
                  ingredients: _ingredientsController.text,
                  instructions: _instructionsController.text,
                  userId: widget.userId, // Ensure this matches your user identification
                );

                try {
                  await _recipeService.uploadRecipe(recipe, _image);
                  // Optionally, clear the fields and show a success message
                  _titleController.clear();
                  _ingredientsController.clear();
                  _instructionsController.clear();
                  setState(() {
                    _image = null;
                  });
                  ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Recipe uploaded successfully!')));
                } catch (e) {
                  ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to upload recipe: \$e')));
                }
              },
              child: Text('Upload Recipe'),
            ),
          ],
        ),
      ),
    );
  }
}

Step 4: Displaying Recipes

Implement the recipe listing functionality.

1. Recipe Feed UI

Display a list of recipes using ListView.builder and StreamBuilder:

import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'recipe_model.dart';

class RecipeFeed extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Recipes')),
      body: StreamBuilder<QuerySnapshot>(
        stream: FirebaseFirestore.instance.collection('recipes').snapshots(),
        builder: (BuildContext context, AsyncSnapshot<QuerySnapshot> snapshot) {
          if (snapshot.hasError) {
            return Center(child: Text('Something went wrong'));
          }

          if (snapshot.connectionState == ConnectionState.waiting) {
            return Center(child: CircularProgressIndicator());
          }

          return ListView(
            children: snapshot.data!.docs.map((DocumentSnapshot document) {
              Recipe recipe = Recipe.fromJson(document.data() as Map<String, dynamic>, document.id);

              return Card(
                margin: EdgeInsets.all(10),
                child: ListTile(
                  leading: recipe.imageUrl != null && recipe.imageUrl!.isNotEmpty
                      ? Image.network(recipe.imageUrl!, width: 50, height: 50, fit: BoxFit.cover)
                      : SizedBox(width: 50, height: 50, child: Placeholder()),  // Placeholder for recipes without images
                  title: Text(recipe.title ?? 'No Title'),
                  subtitle: Text('Ingredients: \${recipe.ingredients ?? 'Not available'}'),
                  onTap: () {
                    // Navigate to detailed recipe view
                    Navigator.push(
                      context,
                      MaterialPageRoute(
                        builder: (context) => RecipeDetailScreen(recipeId: recipe.id!),
                      ),
                    );
                  },
                ),
              );
            }).toList(),
          );
        },
      ),
    );
  }
}

Step 5: Recipe Detail View

Implement a screen to show the details of a recipe:

import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'recipe_model.dart';

class RecipeDetailScreen extends StatelessWidget {
  final String recipeId;

  RecipeDetailScreen({required this.recipeId});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Recipe Details')),
      body: FutureBuilder<DocumentSnapshot>(
        future: FirebaseFirestore.instance.collection('recipes').doc(recipeId).get(),
        builder: (BuildContext context, AsyncSnapshot<DocumentSnapshot> snapshot) {
          if (snapshot.hasError) {
            return Center(child: Text('Something went wrong'));
          }

          if (snapshot.connectionState == ConnectionState.waiting) {
            return Center(child: CircularProgressIndicator());
          }

          if (!snapshot.hasData || snapshot.data == null || !snapshot.data!.exists) {
              return Center(child: Text('Recipe not found'));
          }

          Recipe recipe = Recipe.fromJson(snapshot.data!.data() as Map<String, dynamic>, snapshot.data!.id);

          return Padding(
            padding: const EdgeInsets.all(16.0),
            child: ListView(
              children: [
                Text(recipe.title ?? 'No Title', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
                SizedBox(height: 10),
                if (recipe.imageUrl != null && recipe.imageUrl!.isNotEmpty)
                  Image.network(recipe.imageUrl!, height: 200, width: double.infinity, fit: BoxFit.cover)
                else
                  SizedBox(height: 200, child: Placeholder()),
                SizedBox(height: 20),
                Text('Ingredients:', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
                Text(recipe.ingredients ?? 'No ingredients provided'),
                SizedBox(height: 20),
                Text('Instructions:', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
                Text(recipe.instructions ?? 'No instructions provided'),
              ],
            ),
          );
        },
      ),
    );
  }
}

Step 6: Adding Search Functionality

Implement search functionality using Firebase queries.

1. Search Service

Create a method in your recipe service to perform searches:


import 'package:cloud_firestore/cloud_firestore.dart';
import 'recipe_model.dart';

class RecipeSearchService {
    Future<List<Recipe>> searchRecipes(String query) async {
        List<Recipe> recipes = [];

        // Assuming recipes are stored under a collection named 'recipes' in Firestore
        QuerySnapshot snapshot = await FirebaseFirestore.instance
            .collection('recipes')
            .where('title', isGreaterThanOrEqualTo: query)  // Simple text search
            .where('title', isLessThan: query + 'z') // Ensures the query covers all variations of the search term
            .get();

        for (DocumentSnapshot doc in snapshot.docs) {
            // Use the factory constructor to create a Recipe object
            Recipe recipe = Recipe.fromJson(doc.data() as Map<String, dynamic>, doc.id);

            // Add each recipe to the list of recipes
            recipes.add(recipe);
        }

        return recipes;
    }
}
2. Implement Search UI

Create UI for the search screen using a TextField and a list to display the search results.

Conclusion

Building a recipe sharing app with Flutter and Firebase is a complex yet highly rewarding endeavor. By leveraging the power of Flutter for UI development and Firebase for backend services, you can create a feature-rich, scalable application that delights food enthusiasts around the world. With this comprehensive guide, you are well-equipped to embark on this exciting project, bringing your vision of a recipe sharing app to life!