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
- Introduction to React Native and Flutter
- Development Environment Setup
- Project Structure and Fundamentals
- UI Components and Styling
- Building the Notes App
- Database Integration with Appwrite
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:
- User registration and login forms
- Secure storage of credentials
- Session management
- Protected routes/screens
- 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:
- Tracking the current user’s ID
- Adding that user ID to each note when created
- 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 byuser_id - Adding the
user_idfield 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
AuthContextto 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 byuser_id - Adding the
user_idfield 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
AuthProviderto 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:
- Open the Appwrite Console
- Navigate to your database and collection
- Add a new attribute:
- Type: String
- Key: user_id
- Required: Yes
- Default: None
- Array: No
- Add an index for this field to improve query performance
Explanations of Key Concepts
-
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
- In Appwrite,
-
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
-
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
renderEmptyComponentfunction that returns a friendly message - Using FlatList’s
ListEmptyComponentprop to display this when there are no notes - Setting
contentContainerStyleto 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
-
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
-
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
-
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-cliStep 2: Log in to your Expo account
eas loginStep 3: Configure EAS in your project
eas build:configureThis 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 previewTo build for production:
eas build -p android --profile productionFor iOS builds:
eas build -p ios --profile productionStep 6: Submit to App Stores
For submitting to Google Play:
eas submit -p android --latestFor submitting to Apple App Store:
eas submit -p ios --latestFlutter 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_iconsCreate 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:mainStep 3: Generate a keystore for Android
keytool -genkey -v -keystore android/app/upload-keystore.jks -keyalg RSA -keysize 2048 -validity 10000 -alias uploadAdd keystore information to android/key.properties:
storePassword=<password>
keyPassword=<password>
keyAlias=upload
storeFile=upload-keystore.jksUpdate your android/app/build.gradle to use this keystore for release builds.
Step 4: Build the Android APK
flutter build apk --releaseOr build an Android App Bundle (recommended for Play Store):
flutter build appbundle --releaseStep 5: Build for iOS
flutter build ios --releaseThen open the project in Xcode:
open ios/Runner.xcworkspaceUse 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
-
App Signing:
- Both platforms require code signing for distribution
- Android uses keystore files to sign APKs
- iOS uses Apple Developer certificates and provisioning profiles
-
App Store Guidelines:
- Each platform has specific requirements for acceptance
- Privacy policies, content ratings, and metadata requirements must be met
-
Versioning:
- Semantic versioning helps users understand update significance
- Version codes/build numbers must increase with each update
-
Environment Management:
- Production builds should use production API endpoints
- Environment variables should be configured for different build types
-
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:
- User authentication with sign-up and login
- Creating, reading, updating, and deleting notes
- User-specific content filtering
- Handling empty states for better UX
- 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