Cross-Platform Mobile App Development Tutorial (React Native and Flutter Side by Side) - Part 3

This is the third part of the tutorial that teaches both React Native and Flutter side by side, this will allow to build the same app in both frameworks.

Table of Contents

  1. Introduction to React Native and Flutter
  2. Development Environment Setup
  3. Project Structure and Fundamentals
  4. UI Components and Styling
  5. Building the Notes App
  6. Database Integration with Appwrite
  1. Authentication for Notes App
  2. Filtering User Notes
  3. No Note Display
  4. Building & Publishing (EAS)

7. Authentication for Notes App

React Native vs Flutter Implementation

Let’s continue our tutorial by implementing authentication for our Notes application. This is a crucial part that will allow users to have personalized experiences with their own sets of notes.

7.1 Understanding Authentication in Mobile Apps

Authentication is the process of verifying a user’s identity. In modern mobile applications, this typically involves:

  1. User registration and login forms
  2. Secure storage of credentials
  3. Session management
  4. Protected routes/screens
  5. User-specific data filtering

Both React Native and Flutter provide ways to implement authentication, and we’ll be using Appwrite as our backend authentication service for both platforms.

7.2 Setting Up Authentication with Appwrite

Let’s start by implementing authentication services in both React Native and Flutter.

React Native: Authentication Service

First, let’s create an authentication service file to handle interactions with Appwrite:

// src/services/auth.service.js
import { ID } from "appwrite";
import { appwriteClient } from "../config/appwrite";

class AuthService {
  // Reference to Appwrite Account service
  account;

  constructor() {
    this.account = appwriteClient.account;
  }

  // Register a new user
  async createAccount(email, password, name) {
    try {
      // Create a new account using Appwrite SDK
      const userAccount = await this.account.create(
        ID.unique(), // Generate a unique ID
        email,
        password,
        name
      );

      // If account creation is successful, automatically log the user in
      if (userAccount) {
        return this.login(email, password);
      } else {
        return userAccount;
      }
    } catch (error) {
      console.error("Error creating account:", error);
      throw error;
    }
  }

  // Log in an existing user
  async login(email, password) {
    try {
      // Create an email session using Appwrite SDK
      return await this.account.createEmailSession(email, password);
    } catch (error) {
      console.error("Error logging in:", error);
      throw error;
    }
  }

  // Get current session/user
  async getCurrentUser() {
    try {
      // Get current account information
      return await this.account.get();
    } catch (error) {
      console.error("Error getting current user:", error);
      return null; // Return null if no user is logged in
    }
  }

  // Log out the current user
  async logout() {
    try {
      // Delete all sessions for the current user
      return await this.account.deleteSession("current");
    } catch (error) {
      console.error("Error logging out:", error);
      throw error;
    }
  }
}

const authService = new AuthService();
export default authService;
Flutter: Authentication Service

Now, let’s create a similar authentication service for Flutter:

// lib/services/auth_service.dart
import 'package:appwrite/appwrite.dart';
import 'package:appwrite/models.dart';
import '../config/appwrite_config.dart';

class AuthService {
  // Reference to Appwrite Account service
  late final Account account;

  AuthService() {
    // Initialize the Account service with our Appwrite client
    account = Account(AppwriteConfig.client);
  }

  // Register a new user
  Future<Session> createAccount(String email, String password, String name) async {
    try {
      // Create a new account using Appwrite SDK
      final user = await account.create(
        userId: ID.unique(), // Generate a unique ID
        email: email,
        password: password,
        name: name,
      );

      // If account creation is successful, automatically log the user in
      if (user.$id.isNotEmpty) {
        return login(email, password);
      } else {
        throw Exception('Failed to create account');
      }
    } catch (error) {
      print('Error creating account: $error');
      rethrow; // Re-throw error for handling in UI
    }
  }

  // Log in an existing user
  Future<Session> login(String email, String password) async {
    try {
      // Create an email session using Appwrite SDK
      return await account.createEmailSession(
        email: email,
        password: password,
      );
    } catch (error) {
      print('Error logging in: $error');
      rethrow;
    }
  }

  // Get current session/user
  Future<User?> getCurrentUser() async {
    try {
      // Get current account information
      return await account.get();
    } catch (error) {
      print('Error getting current user: $error');
      return null; // Return null if no user is logged in
    }
  }

  // Log out the current user
  Future<void> logout() async {
    try {
      // Delete the current session
      await account.deleteSession(sessionId: 'current');
    } catch (error) {
      print('Error logging out: $error');
      rethrow;
    }
  }
}

7.3 Creating Authentication Context and Provider

To manage authentication state across our app, we’ll create an Auth Context. This will allow any component in our app to access authentication information and functions.

React Native: Auth Context and Provider
// src/contexts/AuthContext.js
import React, { createContext, useState, useEffect, useContext } from "react";
import authService from "../services/auth.service";

// Create a new context
export const AuthContext = createContext();

// Custom hook to use the auth context
export const useAuth = () => {
  return useContext(AuthContext);
};

// Provider component that wraps the app
export const AuthProvider = ({ children }) => {
  // State to hold user information
  const [user, setUser] = useState(null);
  // State to track loading status during authentication checks
  const [loading, setLoading] = useState(true);

  // Check for existing session on app load
  useEffect(() => {
    const checkUserStatus = async () => {
      try {
        const currentUser = await authService.getCurrentUser();
        setUser(currentUser);
      } catch (error) {
        console.error("Auth status check failed:", error);
        setUser(null);
      } finally {
        setLoading(false);
      }
    };

    checkUserStatus();
  }, []);

  // Register a new user
  const register = async (email, password, name) => {
    try {
      const session = await authService.createAccount(email, password, name);
      // After registration, fetch and set the user
      const currentUser = await authService.getCurrentUser();
      setUser(currentUser);
      return { success: true, data: session };
    } catch (error) {
      console.error("Registration failed:", error);
      return { success: false, error };
    }
  };

  // Login existing user
  const login = async (email, password) => {
    try {
      const session = await authService.login(email, password);
      // After login, fetch and set the user
      const currentUser = await authService.getCurrentUser();
      setUser(currentUser);
      return { success: true, data: session };
    } catch (error) {
      console.error("Login failed:", error);
      return { success: false, error };
    }
  };

  // Logout user
  const logout = async () => {
    try {
      await authService.logout();
      setUser(null);
      return { success: true };
    } catch (error) {
      console.error("Logout failed:", error);
      return { success: false, error };
    }
  };

  // Value to be provided to consumers of this context
  const value = {
    user,
    loading,
    register,
    login,
    logout,
    isAuthenticated: !!user,
  };

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};

Now, let’s wrap our app with this provider in the main App component:

// App.js
import React from "react";
import { NavigationContainer } from "@react-navigation/native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { AuthProvider } from "./src/contexts/AuthContext";
import HomeScreen from "./src/screens/HomeScreen";
import NotesScreen from "./src/screens/NotesScreen";
import AuthScreen from "./src/screens/AuthScreen";
// Import other screens as needed

const Stack = createNativeStackNavigator();

export default function App() {
  return (
    <AuthProvider>
      <NavigationContainer>
        <Stack.Navigator>
          <Stack.Screen name="Auth" component={AuthScreen} />
          <Stack.Screen name="Home" component={HomeScreen} />
          <Stack.Screen name="Notes" component={NotesScreen} />
          {/* Add other screens here */}
        </Stack.Navigator>
      </NavigationContainer>
    </AuthProvider>
  );
}
Flutter: Auth Provider

For Flutter, we’ll use a similar approach with providers:

// lib/providers/auth_provider.dart
import 'package:flutter/material.dart';
import 'package:appwrite/models.dart';
import '../services/auth_service.dart';

class AuthProvider extends ChangeNotifier {
  // Reference to our auth service
  final AuthService _authService = AuthService();

  // Current user
  User? _user;
  User? get user => _user;

  // Loading state
  bool _loading = true;
  bool get loading => _loading;

  // Authentication state
  bool get isAuthenticated => _user != null;

  // Constructor - checks for existing session
  AuthProvider() {
    _checkUserStatus();
  }

  // Check if user is already logged in
  Future<void> _checkUserStatus() async {
    try {
      _user = await _authService.getCurrentUser();
    } catch (e) {
      _user = null;
    } finally {
      _loading = false;
      notifyListeners();
    }
  }

  // Register a new user
  Future<bool> register(String email, String password, String name) async {
    try {
      await _authService.createAccount(email, password, name);
      _user = await _authService.getCurrentUser();
      notifyListeners();
      return true;
    } catch (e) {
      print('Registration error: $e');
      return false;
    }
  }

  // Login existing user
  Future<bool> login(String email, String password) async {
    try {
      await _authService.login(email, password);
      _user = await _authService.getCurrentUser();
      notifyListeners();
      return true;
    } catch (e) {
      print('Login error: $e');
      return false;
    }
  }

  // Logout user
  Future<bool> logout() async {
    try {
      await _authService.logout();
      _user = null;
      notifyListeners();
      return true;
    } catch (e) {
      print('Logout error: $e');
      return false;
    }
  }
}

Now, let’s integrate this provider into our main Flutter app:

// lib/main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'providers/auth_provider.dart';
import 'screens/auth_screen.dart';
import 'screens/home_screen.dart';
import 'screens/notes_screen.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => AuthProvider()),
        // Add other providers as needed
      ],
      child: MaterialApp(
        title: 'Notes App',
        theme: ThemeData(
          primarySwatch: Colors.blue,
          useMaterial3: true,
        ),
        initialRoute: '/auth',
        routes: {
          '/auth': (context) => const AuthScreen(),
          '/home': (context) => const HomeScreen(),
          '/notes': (context) => const NotesScreen(),
        },
      ),
    );
  }
}

7.4 Creating Authentication Screens

Let’s build login and registration screens for both platforms:

React Native: Auth Screen
// src/screens/AuthScreen.js
import React, { useState, useEffect } from "react";
import {
  View,
  Text,
  TextInput,
  TouchableOpacity,
  StyleSheet,
  KeyboardAvoidingView,
  Platform,
} from "react-native";
import { useAuth } from "../contexts/AuthContext";

const AuthScreen = ({ navigation }) => {
  // State for form fields
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [name, setName] = useState("");
  const [isLogin, setIsLogin] = useState(true); // Toggle between login and register
  const [errorMessage, setErrorMessage] = useState("");

  // Get auth context
  const { user, login, register, isAuthenticated, loading } = useAuth();

  // Check if user is already authenticated
  useEffect(() => {
    if (isAuthenticated && !loading) {
      navigation.replace("Home"); // Redirect to Home if already logged in
    }
  }, [isAuthenticated, loading, navigation]);

  // Handle form submission
  const handleSubmit = async () => {
    setErrorMessage("");

    if (!email || !password) {
      setErrorMessage("Email and password are required");
      return;
    }

    if (!isLogin && !name) {
      setErrorMessage("Name is required for registration");
      return;
    }

    try {
      let result;

      if (isLogin) {
        // Handle login
        result = await login(email, password);
      } else {
        // Handle registration
        result = await register(email, password, name);
      }

      if (result.success) {
        // Navigate to home screen on success
        navigation.replace("Home");
      } else {
        // Display error message
        setErrorMessage(result.error?.message || "Authentication failed");
      }
    } catch (error) {
      setErrorMessage("An unexpected error occurred");
      console.error("Auth error:", error);
    }
  };

  // Toggle between login and register forms
  const toggleAuthMode = () => {
    setIsLogin(!isLogin);
    setErrorMessage("");
  };

  if (loading) {
    return (
      <View style={styles.container}>
        <Text>Loading...</Text>
      </View>
    );
  }

  return (
    <KeyboardAvoidingView
      style={styles.container}
      behavior={Platform.OS === "ios" ? "padding" : "height"}
    >
      <View style={styles.formContainer}>
        <Text style={styles.title}>{isLogin ? "Login" : "Register"}</Text>

        {/* Name field (registration only) */}
        {!isLogin && (
          <TextInput
            style={styles.input}
            placeholder="Name"
            value={name}
            onChangeText={setName}
            autoCapitalize="words"
          />
        )}

        {/* Email field */}
        <TextInput
          style={styles.input}
          placeholder="Email"
          value={email}
          onChangeText={setEmail}
          keyboardType="email-address"
          autoCapitalize="none"
        />

        {/* Password field */}
        <TextInput
          style={styles.input}
          placeholder="Password"
          value={password}
          onChangeText={setPassword}
          secureTextEntry
        />

        {/* Error message */}
        {errorMessage ? (
          <Text style={styles.errorText}>{errorMessage}</Text>
        ) : null}

        {/* Submit button */}
        <TouchableOpacity style={styles.button} onPress={handleSubmit}>
          <Text style={styles.buttonText}>
            {isLogin ? "Login" : "Register"}
          </Text>
        </TouchableOpacity>

        {/* Toggle between login and register */}
        <TouchableOpacity style={styles.switchButton} onPress={toggleAuthMode}>
          <Text style={styles.switchText}>
            {isLogin
              ? "Don't have an account? Register"
              : "Already have an account? Login"}
          </Text>
        </TouchableOpacity>
      </View>
    </KeyboardAvoidingView>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: "center",
    padding: 20,
    backgroundColor: "#f5f5f5",
  },
  formContainer: {
    backgroundColor: "white",
    padding: 20,
    borderRadius: 10,
    shadowColor: "#000",
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.1,
    shadowRadius: 5,
    elevation: 3,
  },
  title: {
    fontSize: 24,
    fontWeight: "bold",
    marginBottom: 20,
    textAlign: "center",
  },
  input: {
    height: 50,
    borderWidth: 1,
    borderColor: "#ddd",
    borderRadius: 5,
    marginBottom: 15,
    paddingHorizontal: 10,
    backgroundColor: "#f9f9f9",
  },
  button: {
    backgroundColor: "#007BFF",
    padding: 15,
    borderRadius: 5,
    alignItems: "center",
  },
  buttonText: {
    color: "white",
    fontSize: 16,
    fontWeight: "bold",
  },
  switchButton: {
    marginTop: 15,
    alignItems: "center",
  },
  switchText: {
    color: "#007BFF",
  },
  errorText: {
    color: "red",
    marginBottom: 10,
    textAlign: "center",
  },
});

export default AuthScreen;
Flutter: Auth Screen

Now let’s create an equivalent authentication screen for Flutter:

// lib/screens/auth_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/auth_provider.dart';

class AuthScreen extends StatefulWidget {
  const AuthScreen({Key? key}) : super(key: key);

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

class _AuthScreenState extends State<AuthScreen> {
  // Form controllers
  final TextEditingController _emailController = TextEditingController();
  final TextEditingController _passwordController = TextEditingController();
  final TextEditingController _nameController = TextEditingController();

  // Form key for validation
  final _formKey = GlobalKey<FormState>();

  // State
  bool _isLogin = true;
  String? _errorMessage;
  bool _isLoading = false;

  @override
  void initState() {
    super.initState();
    // Check if user is already logged in
    WidgetsBinding.instance.addPostFrameCallback((_) {
      final authProvider = Provider.of<AuthProvider>(context, listen: false);
      if (!authProvider.loading && authProvider.isAuthenticated) {
        Navigator.of(context).pushReplacementNamed('/home');
      }
    });
  }

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    _nameController.dispose();
    super.dispose();
  }

  // Handle form submission
  Future<void> _submitForm() async {
    if (_formKey.currentState?.validate() != true) {
      return;
    }

    setState(() {
      _isLoading = true;
      _errorMessage = null;
    });

    final authProvider = Provider.of<AuthProvider>(context, listen: false);
    bool success;

    try {
      if (_isLogin) {
        // Handle login
        success = await authProvider.login(
          _emailController.text.trim(),
          _passwordController.text,
        );
      } else {
        // Handle registration
        success = await authProvider.register(
          _emailController.text.trim(),
          _passwordController.text,
          _nameController.text.trim(),
        );
      }

      if (success && mounted) {
        // Navigate to home on success
        Navigator.of(context).pushReplacementNamed('/home');
      } else if (mounted) {
        setState(() {
          _errorMessage = 'Authentication failed. Please try again.';
        });
      }
    } catch (e) {
      if (mounted) {
        setState(() {
          _errorMessage = e.toString();
        });
      }
    } finally {
      if (mounted) {
        setState(() {
          _isLoading = false;
        });
      }
    }
  }

  // Toggle between login and register
  void _toggleAuthMode() {
    setState(() {
      _isLogin = !_isLogin;
      _errorMessage = null;
    });
  }

  @override
  Widget build(BuildContext context) {
    final authProvider = Provider.of<AuthProvider>(context);

    // Show loading spinner if checking auth status
    if (authProvider.loading) {
      return const Scaffold(
        body: Center(
          child: CircularProgressIndicator(),
        ),
      );
    }

    return Scaffold(
      body: Center(
        child: SafeArea(
          child: SingleChildScrollView(
            padding: const EdgeInsets.all(24.0),
            child: Form(
              key: _formKey,
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                crossAxisAlignment: CrossAxisAlignment.stretch,
                children: [
                  // Title
                  Text(
                    _isLogin ? 'Login' : 'Register',
                    style: Theme.of(context).textTheme.headlineMedium,
                    textAlign: TextAlign.center,
                  ),
                  const SizedBox(height: 30),

                  // Name field (registration only)
                  if (!_isLogin)
                    TextFormField(
                      controller: _nameController,
                      decoration: const InputDecoration(
                        labelText: 'Name',
                        border: OutlineInputBorder(),
                        prefixIcon: Icon(Icons.person),
                      ),
                      validator: (value) {
                        if (!_isLogin && (value == null || value.isEmpty)) {
                          return 'Please enter your name';
                        }
                        return null;
                      },
                    ),
                  if (!_isLogin) const SizedBox(height: 16),

                  // Email field
                  TextFormField(
                    controller: _emailController,
                    decoration: const InputDecoration(
                      labelText: 'Email',
                      border: OutlineInputBorder(),
                      prefixIcon: Icon(Icons.email),
                    ),
                    keyboardType: TextInputType.emailAddress,
                    validator: (value) {
                      if (value == null || value.isEmpty) {
                        return 'Please enter your email';
                      }
                      if (!value.contains('@')) {
                        return 'Please enter a valid email';
                      }
                      return null;
                    },
                  ),
                  const SizedBox(height: 16),

                  // Password field
                  TextFormField(
                    controller: _passwordController,
                    decoration: const InputDecoration(
                      labelText: 'Password',
                      border: OutlineInputBorder(),
                      prefixIcon: Icon(Icons.lock),
                    ),
                    obscureText: true,
                    validator: (value) {
                      if (value == null || value.isEmpty) {
                        return 'Please enter your password';
                      }
                      if (value.length < 6) {
                        return 'Password must be at least 6 characters';
                      }
                      return null;
                    },
                  ),
                  const SizedBox(height: 24),

                  // Error message
                  if (_errorMessage != null)
                    Padding(
                      padding: const EdgeInsets.only(bottom: 16.0),
                      child: Text(
                        _errorMessage!,
                        style: const TextStyle(color: Colors.red),
                        textAlign: TextAlign.center,
                      ),
                    ),

                  // Submit button
                  ElevatedButton(
                    onPressed: _isLoading ? null : _submitForm,
                    style: ElevatedButton.styleFrom(
                      padding: const EdgeInsets.symmetric(vertical: 16),
                    ),
                    child: _isLoading
                        ? const CircularProgressIndicator()
                        : Text(_isLogin ? 'Login' : 'Register'),
                  ),
                  const SizedBox(height: 16),

                  // Toggle button
                  TextButton(
                    onPressed: _toggleAuthMode,
                    child: Text(
                      _isLogin
                          ? 'Don\'t have an account? Register'
                          : 'Already have an account? Login',
                    ),
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}

7.5 Implementing Logout Functionality

Let’s add logout functionality to both platforms:

React Native: Logout Button Component
// src/components/LogoutButton.js
import React from "react";
import { TouchableOpacity, Text, StyleSheet } from "react-native";
import { useAuth } from "../contexts/AuthContext";

const LogoutButton = ({ navigation }) => {
  const { logout } = useAuth();

  const handleLogout = async () => {
    try {
      const result = await logout();
      if (result.success) {
        // Navigate to Auth screen after successful logout
        navigation.replace("Auth");
      }
    } catch (error) {
      console.error("Logout error:", error);
    }
  };

  return (
    <TouchableOpacity style={styles.button} onPress={handleLogout}>
      <Text style={styles.buttonText}>Logout</Text>
    </TouchableOpacity>
  );
};

const styles = StyleSheet.create({
  button: {
    backgroundColor: "#f44336",
    paddingHorizontal: 15,
    paddingVertical: 8,
    borderRadius: 5,
  },
  buttonText: {
    color: "white",
    fontWeight: "bold",
  },
});

export default LogoutButton;

Now, let’s add this LogoutButton to our HomeScreen:

// src/screens/HomeScreen.js
import React, { useEffect } from "react";
import { View, Text, StyleSheet, TouchableOpacity } from "react-native";
import { useAuth } from "../contexts/AuthContext";
import LogoutButton from "../components/LogoutButton";

const HomeScreen = ({ navigation }) => {
  const { user, isAuthenticated, loading } = useAuth();

  // Redirect unauthenticated users to Auth screen
  useEffect(() => {
    if (!loading && !isAuthenticated) {
      navigation.replace("Auth");
    }
  }, [isAuthenticated, loading, navigation]);

  // Add LogoutButton to navigation header
  useEffect(() => {
    navigation.setOptions({
      headerRight: () => <LogoutButton navigation={navigation} />,
    });
  }, [navigation]);

  if (loading) {
    return (
      <View style={styles.container}>
        <Text>Loading...</Text>
      </View>
    );
  }

  return (
    <View style={styles.container}>
      <Text style={styles.welcomeText}>Welcome, {user?.name || "User"}!</Text>
      <TouchableOpacity
        style={styles.button}
        onPress={() => navigation.navigate("Notes")}
      >
        <Text style={styles.buttonText}>View Notes</Text>
      </TouchableOpacity>
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: "center",
    alignItems: "center",
    padding: 20,
    backgroundColor: "#f5f5f5",
  },
  welcomeText: {
    fontSize: 24,
    marginBottom: 20,
    textAlign: "center",
  },
  button: {
    backgroundColor: "#007BFF",
    padding: 15,
    borderRadius: 5,
    width: "80%",
    alignItems: "center",
  },
  buttonText: {
    color: "white",
    fontSize: 16,
    fontWeight: "bold",
  },
});

export default HomeScreen;
Flutter: Logout Button

Let’s implement the logout functionality for Flutter:

// lib/widgets/logout_button.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/auth_provider.dart';

class LogoutButton extends StatelessWidget {
  const LogoutButton({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return IconButton(
      icon: const Icon(Icons.logout),
      tooltip: 'Logout',
      onPressed: () async {
        // Show confirmation dialog
        final shouldLogout = await showDialog<bool>(
          context: context,
          builder: (context) => AlertDialog(
            title: const Text('Logout'),
            content: const Text('Are you sure you want to logout?'),
            actions: [
              TextButton(
                onPressed: () => Navigator.of(context).pop(false),
                child: const Text('Cancel'),
              ),
              TextButton(
                onPressed: () => Navigator.of(context).pop(true),
                child: const Text('Logout'),
              ),
            ],
          ),
        );

        // If user confirmed, perform logout
        if (shouldLogout == true) {
          final authProvider = Provider.of<AuthProvider>(context, listen: false);
          final success = await authProvider.logout();

          if (success && context.mounted) {
            // Navigate to auth screen after logout
            Navigator.of(context).pushReplacementNamed('/auth');
          } else if (context.mounted) {
            // Show error message
            ScaffoldMessenger.of(context).showSnackBar(
              const SnackBar(content: Text('Logout failed. Please try again.')),
            );
          }
        }
      },
    );
  }
}

Now, let’s update our Flutter HomeScreen to include this logout button:

// lib/screens/home_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/auth_provider.dart';
import '../widgets/logout_button.dart';

class HomeScreen extends StatefulWidget {
  const HomeScreen({Key? key}) : super(key: key);

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

class _HomeScreenState extends State<HomeScreen> {
  @override
  void initState() {
    super.initState();
    // Check authentication status
    WidgetsBinding.instance.addPostFrameCallback((_) {
      final authProvider = Provider.of<AuthProvider>(context, listen: false);
      if (!authProvider.loading && !authProvider.isAuthenticated) {
        Navigator.of(context).pushReplacementNamed('/auth');
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    final authProvider = Provider.of<AuthProvider>(context);

    if (authProvider.loading) {
      return const Scaffold(
        body: Center(
          child: CircularProgressIndicator(),
        ),
      );
    }

    return Scaffold(
      appBar: AppBar(
        title: const Text('Notes App'),
        actions: const [
          LogoutButton(),
        ],
      ),
      body: Center(
        child: Padding(
          padding: const EdgeInsets.all(24.0),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text(
                'Welcome, ${authProvider.user?.name ?? 'User'}!',
                style: Theme.of(context).textTheme.headlineSmall,
                textAlign: TextAlign.center,
              ),
              const SizedBox(height: 30),
              ElevatedButton(
                style: ElevatedButton.styleFrom(
                  padding: const EdgeInsets.symmetric(
                    horizontal: 40,
                    vertical: 15,
                  ),
                ),
                onPressed: () {
                  Navigator.of(context).pushNamed('/notes');
                },
                child: const Text('View My Notes'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

7.6 Redirecting Users Based on Authentication Status

Let’s implement a mechanism to redirect users appropriately based on their authentication status:

React Native: Auth Navigation Management

Let’s create a component to manage navigation based on authentication:

// src/navigation/AuthNavigator.js
import React, { useEffect } from "react";
import { View, ActivityIndicator } from "react-native";
import { NavigationContainer } from "@react-navigation/native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { useAuth } from "../contexts/AuthContext";

// Import screens
import AuthScreen from "../screens/AuthScreen";
import HomeScreen from "../screens/HomeScreen";
import NotesScreen from "../screens/NotesScreen";
import NoteEditScreen from "../screens/NoteEditScreen";

const Stack = createNativeStackNavigator();

// Auth stack - screens for unauthenticated users
const AuthStack = () => (
  <Stack.Navigator>
    <Stack.Screen
      name="Auth"
      component={AuthScreen}
      options={{ headerShown: false }}
    />
  </Stack.Navigator>
);

// App stack - screens for authenticated users
const AppStack = () => (
  <Stack.Navigator>
    <Stack.Screen name="Home" component={HomeScreen} />
    <Stack.Screen name="Notes" component={NotesScreen} />
    <Stack.Screen
      name="NoteEdit"
      component={NoteEditScreen}
      options={{ title: "Edit Note" }}
    />
  </Stack.Navigator>
);

// Main navigator component
const AuthNavigator = () => {
  const { isAuthenticated, loading } = useAuth();

  if (loading) {
    return (
      <View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
        <ActivityIndicator size="large" color="#007BFF" />
      </View>
    );
  }

  return (
    <NavigationContainer>
      {isAuthenticated ? <AppStack /> : <AuthStack />}
    </NavigationContainer>
  );
};

export default AuthNavigator;

Now let’s update our App.js to use this navigator:

// App.js
import React from "react";
import { AuthProvider } from "./src/contexts/AuthContext";
import AuthNavigator from "./src/navigation/AuthNavigator";

export default function App() {
  return (
    <AuthProvider>
      <AuthNavigator />
    </AuthProvider>
  );
}
Flutter: Auth Navigation Management

For Flutter, let’s create a similar navigation system:

// lib/navigation/auth_navigator.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/auth_provider.dart';
import '../screens/auth_screen.dart';
import '../screens/home_screen.dart';
import '../screens/notes_screen.dart';
import '../screens/note_edit_screen.dart';

class AuthNavigator extends StatelessWidget {
  const AuthNavigator({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final authProvider = Provider.of<AuthProvider>(context);

    // Show loading indicator while checking auth status
    if (authProvider.loading) {
      return const MaterialApp(
        home: Scaffold(
          body: Center(
            child: CircularProgressIndicator(),
          ),
        ),
      );
    }

    return MaterialApp(
      title: 'Notes App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        useMaterial3: true,
      ),
      // Choose initial route based on auth status
      initialRoute: authProvider.isAuthenticated ? '/home' : '/auth',
      routes: {
        '/auth': (context) => const AuthScreen(),
        '/home': (context) => const HomeScreen(),
        '/notes': (context) => const NotesScreen(),
        '/note_edit': (context) => const NoteEditScreen(),
      },
    );
  }
}

Now let’s update our main.dart file:

// lib/main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'providers/auth_provider.dart';
import 'navigation/auth_navigator.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => AuthProvider()),
        // Add other providers here
      ],
      child: const AuthNavigator(),
    );
  }
}

8. Filtering User Notes

Let’s continue our tutorial by implementing user-specific note filtering in both React Native and Flutter. This is a crucial feature that ensures users only see their own notes once we’ve implemented authentication.

Understanding the Concept

When working with multi-user applications, it’s important to filter data so users only see their own content. This requires:

  1. Tracking the current user’s ID
  2. Adding that user ID to each note when created
  3. Filtering notes based on the user ID when fetching from the database

Let’s implement this in both frameworks:

React Native Implementation

Step 1: Update the Note Service

First, we need to modify our note service to fetch only the current user’s notes:

// services/noteService.js
import { appwriteDatabase } from "./appwriteConfig";
import { Query } from "appwrite";

export const getNotes = async (userId) => {
  try {
    const response = await appwriteDatabase.listDocuments(
      process.env.EXPO_PUBLIC_DATABASE_ID,
      process.env.EXPO_PUBLIC_COLLECTION_ID,
      [Query.equal("user_id", userId)] // Filter by user_id
    );

    return response.documents;
  } catch (error) {
    console.error("Error fetching notes:", error);
    return [];
  }
};

export const addNote = async (text, userId) => {
  try {
    const response = await appwriteDatabase.createDocument(
      process.env.EXPO_PUBLIC_DATABASE_ID,
      process.env.EXPO_PUBLIC_COLLECTION_ID,
      "unique()",
      {
        text,
        user_id: userId, // Add user ID to the note
      }
    );

    return response;
  } catch (error) {
    console.error("Error adding note:", error);
    throw error;
  }
};

// The update and delete functions remain the same

The key changes are:

  • Using Appwrite’s Query.equal() to filter notes by user_id
  • Adding the user_id field when creating new notes

Step 2: Update the Notes Screen

Now we need to modify our NotesScreen to pass the current user’s ID when fetching notes:

// screens/NotesScreen.js
import React, { useEffect, useState, useContext } from "react";
import { View, Text, FlatList, TouchableOpacity } from "react-native";
import {
  getNotes,
  addNote,
  deleteNote,
  updateNote,
} from "../services/noteService";
import NoteItem from "../components/NoteItem";
import AddNoteModal from "../components/AddNoteModal";
import { AuthContext } from "../context/AuthContext";

const NotesScreen = () => {
  const [notes, setNotes] = useState([]);
  const [isAddModalVisible, setIsAddModalVisible] = useState(false);
  const [isLoading, setIsLoading] = useState(true);
  const { user } = useContext(AuthContext); // Get the current user from context

  const fetchNotes = async () => {
    setIsLoading(true);
    try {
      // Pass the user ID when fetching notes
      const fetchedNotes = await getNotes(user.$id);
      setNotes(fetchedNotes);
    } catch (error) {
      console.error("Failed to fetch notes:", error);
    } finally {
      setIsLoading(false);
    }
  };

  useEffect(() => {
    if (user) {
      fetchNotes();
    }
  }, [user]);

  const handleAddNote = async (text) => {
    try {
      // Pass the user ID when adding a note
      const newNote = await addNote(text, user.$id);
      setNotes([newNote, ...notes]);
      setIsAddModalVisible(false);
    } catch (error) {
      console.error("Failed to add note:", error);
    }
  };

  const handleDeleteNote = async (noteId) => {
    try {
      await deleteNote(noteId);
      setNotes(notes.filter((note) => note.$id !== noteId));
    } catch (error) {
      console.error("Failed to delete note:", error);
    }
  };

  const handleUpdateNote = async (noteId, newText) => {
    try {
      const updatedNote = await updateNote(noteId, newText);
      setNotes(notes.map((note) => (note.$id === noteId ? updatedNote : note)));
    } catch (error) {
      console.error("Failed to update note:", error);
    }
  };

  // Render logic remains largely the same
  // ...
};

export default NotesScreen;

The important changes are:

  • Importing and using the AuthContext to access the current user
  • Passing the user ID (user.$id) when fetching and adding notes
  • Only fetching notes when a user is authenticated

Flutter Implementation

Step 1: Update the Note Service

// services/note_service.dart
import 'package:appwrite/appwrite.dart';
import 'package:appwrite/models.dart';
import '../models/note_model.dart';
import 'appwrite_config.dart';

class NoteService {
  final Databases databases;

  NoteService() : databases = Databases(AppwriteConfig.client);

  Future<List<Note>> getNotes(String userId) async {
    try {
      final response = await databases.listDocuments(
        databaseId: AppwriteConfig.databaseId,
        collectionId: AppwriteConfig.collectionId,
        queries: [
          Query.equal('user_id', userId) // Filter by user_id
        ],
      );

      return response.documents.map((doc) => Note.fromJson(doc.data)).toList();
    } catch (e) {
      print('Error fetching notes: $e');
      return [];
    }
  }

  Future<Note?> addNote(String text, String userId) async {
    try {
      final response = await databases.createDocument(
        databaseId: AppwriteConfig.databaseId,
        collectionId: AppwriteConfig.collectionId,
        documentId: ID.unique(),
        data: {
          'text': text,
          'user_id': userId // Add user ID to the note
        },
      );

      return Note.fromJson(response.data);
    } catch (e) {
      print('Error adding note: $e');
      return null;
    }
  }

  // Update and delete methods remain similar
}

The changes mirror those in the React Native version:

  • Using Appwrite’s Query.equal() to filter notes by user_id
  • Adding the user_id field when creating new notes

Step 2: Update the Notes Screen

// screens/notes_screen.dart
import 'package:flutter/material.dart';
import '../models/note_model.dart';
import '../services/note_service.dart';
import '../widgets/note_item.dart';
import '../widgets/add_note_modal.dart';
import '../providers/auth_provider.dart';
import 'package:provider/provider.dart';

class NotesScreen extends StatefulWidget {
  @override
  _NotesScreenState createState() => _NotesScreenState();
}

class _NotesScreenState extends State<NotesScreen> {
  List<Note> notes = [];
  bool isLoading = true;
  final NoteService _noteService = NoteService();

  @override
  void initState() {
    super.initState();
    _fetchNotes();
  }

  Future<void> _fetchNotes() async {
    final authProvider = Provider.of<AuthProvider>(context, listen: false);
    if (authProvider.user == null) return;

    setState(() {
      isLoading = true;
    });

    try {
      // Pass the user ID when fetching notes
      final fetchedNotes = await _noteService.getNotes(authProvider.user!.id);
      setState(() {
        notes = fetchedNotes;
        isLoading = false;
      });
    } catch (e) {
      print('Failed to fetch notes: $e');
      setState(() {
        isLoading = false;
      });
    }
  }

  Future<void> _addNote(String text) async {
    final authProvider = Provider.of<AuthProvider>(context, listen: false);
    if (authProvider.user == null) return;

    try {
      // Pass the user ID when adding a note
      final newNote = await _noteService.addNote(text, authProvider.user!.id);
      if (newNote != null) {
        setState(() {
          notes.insert(0, newNote);
        });
        Navigator.of(context).pop(); // Close the modal
      }
    } catch (e) {
      print('Failed to add note: $e');
    }
  }

  // Delete and update handlers remain similar

  // Build method remains similar
}

Key changes:

  • Using the AuthProvider to get the current user
  • Passing the user ID when fetching and adding notes
  • Only fetching notes when a user is authenticated

Step 3: Update Database Schema

For both implementations, we need to make sure our Appwrite database schema includes a user_id field:

Appwrite Collection Schema:

  1. Open the Appwrite Console
  2. Navigate to your database and collection
  3. Add a new attribute:
    • Type: String
    • Key: user_id
    • Required: Yes
    • Default: None
    • Array: No
  4. Add an index for this field to improve query performance

Explanations of Key Concepts

  1. Query Filtering:

    • In Appwrite, Query.equal('user_id', userId) creates a condition that only returns documents where the ‘user_id’ field matches the provided value
    • This is a common pattern in database queries to filter collections by a specific attribute
  2. User Context:

    • By using context (React) or providers (Flutter), we maintain user state across the app
    • This allows any component to access the current user without passing props through multiple levels
  3. Security Considerations:

    • Client-side filtering is not enough for security - we should also set up database rules in Appwrite to restrict access
    • This can be done using Appwrite permissions to ensure users can only access their own data

Comparing Implementation Approaches

Aspect React Native Flutter
State Management Uses React Context API Uses Provider pattern
API Calls Async/await with try/catch Async/await with try/catch
UI Updates React’s useState and useEffect Flutter’s setState()
Component Structure Functional components with hooks StatefulWidget classes

Both implementations follow similar patterns but use framework-specific approaches for state management and UI rendering.

9. No Note Display

When a user has no notes, we should display a friendly message rather than an empty screen. Let’s implement this feature in both frameworks.

React Native Implementation

Let’s modify our NotesScreen to handle the empty state:

// screens/NotesScreen.js
// ... previous imports and code

const NotesScreen = () => {
  // ... previous state and functions

  const renderEmptyComponent = () => {
    return (
      <View style={styles.emptyContainer}>
        <Text style={styles.emptyText}>You don't have any notes yet.</Text>
        <Text style={styles.emptySubtext}>
          Tap the + button to create your first note!
        </Text>
      </View>
    );
  };

  return (
    <View style={styles.container}>
      <FlatList
        data={notes}
        renderItem={({ item }) => (
          <NoteItem
            note={item}
            onDelete={handleDeleteNote}
            onUpdate={handleUpdateNote}
          />
        )}
        keyExtractor={(item) => item.$id}
        contentContainerStyle={notes.length === 0 ? { flex: 1 } : {}}
        ListEmptyComponent={!isLoading && renderEmptyComponent()}
      />

      {/* Add button and modal remain the same */}

      {isLoading && (
        <View style={styles.loadingContainer}>
          <ActivityIndicator size="large" color="#0000ff" />
        </View>
      )}
    </View>
  );
};

const styles = StyleSheet.create({
  // ... existing styles
  emptyContainer: {
    flex: 1,
    justifyContent: "center",
    alignItems: "center",
    padding: 20,
  },
  emptyText: {
    fontSize: 18,
    fontWeight: "bold",
    marginBottom: 10,
  },
  emptySubtext: {
    fontSize: 16,
    color: "#666",
    textAlign: "center",
  },
});

export default NotesScreen;

The key additions are:

  • A renderEmptyComponent function that returns a friendly message
  • Using FlatList’s ListEmptyComponent prop to display this when there are no notes
  • Setting contentContainerStyle to take full height when empty for proper centering

Flutter Implementation

Similarly, let’s update our Flutter implementation:

// screens/notes_screen.dart
// ... previous imports and code

class _NotesScreenState extends State<NotesScreen> {
  // ... previous state and functions

  Widget _buildEmptyNotesView() {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text(
            "You don't have any notes yet.",
            style: TextStyle(
              fontSize: 18,
              fontWeight: FontWeight.bold,
            ),
          ),
          SizedBox(height: 10),
          Text(
            "Tap the + button to create your first note!",
            style: TextStyle(
              fontSize: 16,
              color: Colors.grey[600],
            ),
            textAlign: TextAlign.center,
          ),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('My Notes'),
        actions: [
          // Logout button or other actions
        ],
      ),
      body: isLoading
          ? Center(child: CircularProgressIndicator())
          : notes.isEmpty
              ? _buildEmptyNotesView()
              : ListView.builder(
                  itemCount: notes.length,
                  itemBuilder: (context, index) {
                    return NoteItem(
                      note: notes[index],
                      onDelete: _deleteNote,
                      onUpdate: _updateNote,
                    );
                  },
                ),
      floatingActionButton: FloatingActionButton(
        child: Icon(Icons.add),
        onPressed: () {
          // Show add note modal
          showModalBottomSheet(
            context: context,
            isScrollControlled: true,
            builder: (context) => AddNoteModal(onSave: _addNote),
          );
        },
      ),
    );
  }
}

Key additions:

  • A _buildEmptyNotesView() method that returns a widget with a friendly message
  • Conditional rendering in the build method to show this view when notes are empty

Explanations of Key Concepts

  1. Conditional Rendering:

    • In both frameworks, we use conditional logic to render different UI based on the state
    • This creates a better user experience by explicitly handling empty states
  2. Empty State Design:

    • Empty states should be informative and guide users on what to do next
    • They should be visually pleasing and not make the app feel broken or incomplete
  3. User Experience:

    • Empty states are an important part of app design that is often overlooked
    • They reduce confusion and frustration when users first start using the app

10. Building & Publishing (EAS)

Finally, let’s look at how to build and publish our applications using Expo Application Services (EAS) for React Native and the equivalent process for Flutter.

React Native with Expo EAS

Expo Application Services (EAS) simplifies the process of building and deploying React Native apps.

Step 1: Install EAS CLI

npm install -g eas-cli

Step 2: Log in to your Expo account

eas login

Step 3: Configure EAS in your project

eas build:configure

This will create an eas.json file in your project root:

{
  "build": {
    "preview": {
      "android": {
        "buildType": "apk"
      }
    },
    "preview2": {
      "android": {
        "gradleCommand": ":app:assembleRelease"
      }
    },
    "preview3": {
      "developmentClient": true
    },
    "production": {}
  }
}

Step 4: Prepare your app.json

Ensure your app.json has the necessary information:

{
  "expo": {
    "name": "My Notes App",
    "slug": "my-notes-app",
    "version": "1.0.0",
    "orientation": "portrait",
    "icon": "./assets/icon.png",
    "splash": {
      "image": "./assets/splash.png",
      "resizeMode": "contain",
      "backgroundColor": "#ffffff"
    },
    "updates": {
      "fallbackToCacheTimeout": 0
    },
    "assetBundlePatterns": ["**/*"],
    "ios": {
      "supportsTablet": true,
      "bundleIdentifier": "com.yourcompany.notesapp"
    },
    "android": {
      "adaptiveIcon": {
        "foregroundImage": "./assets/adaptive-icon.png",
        "backgroundColor": "#FFFFFF"
      },
      "package": "com.yourcompany.notesapp"
    },
    "web": {
      "favicon": "./assets/favicon.png"
    }
  }
}

Step 5: Build your app

To build a preview APK:

eas build -p android --profile preview

To build for production:

eas build -p android --profile production

For iOS builds:

eas build -p ios --profile production

Step 6: Submit to App Stores

For submitting to Google Play:

eas submit -p android --latest

For submitting to Apple App Store:

eas submit -p ios --latest

Flutter Deployment Process

Step 1: Configure app details

Update your pubspec.yaml file:

name: notes_app
description: A Flutter Notes Application
version: 1.0.0+1

environment:
  sdk: ">=2.12.0 <3.0.0"

Step 2: Update app icons and splash screen

Use the flutter_launcher_icons package to generate app icons:

flutter pub add flutter_launcher_icons

Create a configuration in your pubspec.yaml:

flutter_icons:
  android: "launcher_icon"
  ios: true
  image_path: "assets/icon/icon.png"

Run the icon generator:

flutter pub run flutter_launcher_icons:main

Step 3: Generate a keystore for Android

keytool -genkey -v -keystore android/app/upload-keystore.jks -keyalg RSA -keysize 2048 -validity 10000 -alias upload

Add keystore information to android/key.properties:

storePassword=<password>
keyPassword=<password>
keyAlias=upload
storeFile=upload-keystore.jks

Update your android/app/build.gradle to use this keystore for release builds.

Step 4: Build the Android APK

flutter build apk --release

Or build an Android App Bundle (recommended for Play Store):

flutter build appbundle --release

Step 5: Build for iOS

flutter build ios --release

Then open the project in Xcode:

open ios/Runner.xcworkspace

Use Xcode to archive and upload to App Store Connect.

Step 6: Submit to App Stores

For Android:

  • Go to Google Play Console
  • Create a new app or new release
  • Upload your APK or AAB file
  • Complete store listing information and roll out

For iOS:

  • Use App Store Connect
  • Create a new app or new version
  • Complete the app information
  • Submit for review

Key Concepts in App Deployment

  1. App Signing:

    • Both platforms require code signing for distribution
    • Android uses keystore files to sign APKs
    • iOS uses Apple Developer certificates and provisioning profiles
  2. App Store Guidelines:

    • Each platform has specific requirements for acceptance
    • Privacy policies, content ratings, and metadata requirements must be met
  3. Versioning:

    • Semantic versioning helps users understand update significance
    • Version codes/build numbers must increase with each update
  4. Environment Management:

    • Production builds should use production API endpoints
    • Environment variables should be configured for different build types
  5. Continuous Integration/Deployment:

    • EAS can be integrated with CI/CD pipelines
    • Flutter can use tools like Codemagic or Fastlane for CI/CD

Comparison of Deployment Processes

Aspect React Native (Expo) Flutter
Build Tools EAS CLI Flutter CLI
Configuration app.json, eas.json pubspec.yaml, build.gradle
Code Signing Managed by Expo Manual configuration
App Stores Simplified submission Manual submission
Build Infrastructure Cloud-based Local or CI/CD

Summary of Notes App Development

We’ve now completed a full-featured notes application in both React Native and Flutter, covering:

  1. User authentication with sign-up and login
  2. Creating, reading, updating, and deleting notes
  3. User-specific content filtering
  4. Handling empty states for better UX
  5. Building and publishing to app stores

Both frameworks offer powerful tools for building cross-platform mobile applications, with their own strengths and approaches. This parallel implementation demonstrates how similar concepts can be implemented in different ways, helping you understand the underlying principles regardless of the specific technology.

React Native vs Flutter Comparison

Feature React Native Flutter
Language JavaScript/TypeScript Dart
UI Rendering Uses native components via bridge Custom rendering engine (Skia)
Performance Good, but bridge can cause overhead Excellent, close to native
Development Speed Fast with hot reload Fast with hot reload
Component Library Mix of native and custom components Material Design and Cupertino widgets
Learning Curve Moderate (familiar for web devs) Moderate (Dart is similar to many languages)
Community Support Very large, mature ecosystem Growing rapidly, strong Google support
App Size Smaller Larger due to embedded runtime
Code Reuse ~90% between platforms ~90% between platforms
State Management Many options (Redux, Context, MobX) Many options (Provider, Riverpod, Bloc)
Integration with Native Through native modules Through platform channels
Build Process Expo or manual native builds Flutter CLI
Hot Reload Yes Yes
Development Tools Various Flutter DevTools
Testing Framework Jest, React Testing Library Flutter Test
Backend Integration Any REST/GraphQL API Any REST/GraphQL API
Navigation React Navigation, others Navigator 2.0, GoRouter
Animation Animated API, Reanimated Flutter Animation framework

Whether you choose React Native or Flutter for your mobile development needs, both frameworks provide excellent tools for building cross-platform applications.

The side-by-side approach shows how similar concepts are implemented differently in each framework, which should help you transfer knowledge between them and make informed decisions about which one to use for your projects.


By Wahid Hamdi