Cross-Platform Mobile App Development Tutorial (React Native and Flutter Side by Side) - Part 2
This is the second 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
- Authentication
- Deployment and Publishing
6. Database Integration with Appwrite
Now we’ll move into database integration using Appwrite for both our React Native and Flutter apps. This section will cover setting up Appwrite, configuring the SDK, and implementing CRUD (Create, Read, Update, Delete) operations for our notes app.
6.1 Understanding Appwrite
What is Appwrite?
Appwrite is an open-source backend server that provides ready-to-use APIs for building web and mobile applications. It offers authentication, database, storage, and other essential backend services, making it an excellent choice for developers who want to focus on building the frontend while having a robust backend system.
Key features of Appwrite:
- Authentication and user management
- Database collections and documents
- File storage
- Serverless functions
- Realtime subscriptions
- REST and SDK APIs
6.2 Appwrite Project and Database Setup
Let’s set up our Appwrite project for both React Native and Flutter applications.
React Native Setup
- First, create an account on Appwrite if you don’t have one already.
- Create a new project called “NotesApp”.
- Navigate to the “Database” section and create a new database called “NotesDB”.
- Inside the database, create a collection called “notes” with the following attributes:
title(string, required)content(string, required)userId(string, required)createdAt(datetime, required)updatedAt(datetime, required)
- Configure collection permissions to allow read and write access to authenticated users.
- Create indexes for faster querying:
- Create an index on the
userIdfield for filtering notes by user. - Create an index on the
createdAtfield for sorting.
- Create an index on the
Flutter Setup
The Appwrite setup process remains identical for Flutter as it is platform-independent:
- Create the same “NotesApp” project.
- Create “NotesDB” database.
- Create the “notes” collection with the same structure and indexes.
6.3 Environment Variables
Managing environment variables is crucial for keeping sensitive information like API keys separate from your code.
React Native Environment Variables
Let’s set up environment variables in our React Native app:
// Install the necessary package
// npm install react-native-dotenv
// Create a .env file in your project root
// APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
// APPWRITE_PROJECT_ID=your-project-id
// APPWRITE_DATABASE_ID=your-database-id
// APPWRITE_COLLECTION_ID=your-collection-id
Now, configure babel to use these environment variables:
// babel.config.js
module.exports = function (api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
plugins: [
[
"module:react-native-dotenv",
{
moduleName: "@env",
path: ".env",
blacklist: null,
whitelist: null,
safe: false,
allowUndefined: true,
},
],
],
};
};Flutter Environment Variables
In Flutter, we’ll use the flutter_dotenv package:
// Install the package
// Add to pubspec.yaml:
// dependencies:
// flutter_dotenv: ^5.0.2
// Create a .env file in your project root with the same variables as React Native
// APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
// APPWRITE_PROJECT_ID=your-project-id
// APPWRITE_DATABASE_ID=your-database-id
// APPWRITE_COLLECTION_ID=your-collection-idConfigure Flutter to include the .env file in assets:
# pubspec.yaml
flutter:
assets:
- .envThen load the environment variables in your main.dart:
import 'package:flutter_dotenv/flutter_dotenv.dart';
Future main() async {
// Load environment variables before running the app
await dotenv.load(fileName: ".env");
runApp(MyApp());
}6.4 Appwrite SDK Installation and Configuration
Let’s install and configure the Appwrite SDK for both platforms.
React Native Appwrite SDK
Install the required packages:
// Terminal
npm install appwriteCreate a configuration file:
// src/services/appwrite-config.js
import { Client } from "appwrite";
import { APPWRITE_ENDPOINT, APPWRITE_PROJECT_ID } from "@env";
// Initialize the Appwrite client
const client = new Client();
client.setEndpoint(APPWRITE_ENDPOINT).setProject(APPWRITE_PROJECT_ID);
export default client;Flutter Appwrite SDK
Add Appwrite to your Flutter project:
# pubspec.yaml
dependencies:
appwrite: ^8.3.0Create a configuration file:
// lib/services/appwrite_config.dart
import 'package:appwrite/appwrite.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
// Initialize Appwrite client
Client getClient() {
Client client = Client();
return client
.setEndpoint(dotenv.env['APPWRITE_ENDPOINT']!)
.setProject(dotenv.env['APPWRITE_PROJECT_ID']!);
}6.5 Database Service
Let’s create services to interact with our Appwrite database.
React Native Database Service
// src/services/database-service.js
import { Databases, Query } from "appwrite";
import client from "./appwrite-config";
import { APPWRITE_DATABASE_ID, APPWRITE_COLLECTION_ID } from "@env";
// Initialize the Databases SDK
const databases = new Databases(client);
// List all documents/notes in the collection
export const listDocuments = async (queries = []) => {
try {
// Fetch documents from the specified database and collection
// 'queries' parameter allows filtering, sorting, and limiting results
const response = await databases.listDocuments(
APPWRITE_DATABASE_ID,
APPWRITE_COLLECTION_ID,
queries
);
// Return the documents array from the response
return response.documents;
} catch (error) {
// Log and rethrow any errors that occur during the operation
console.error("Error listing documents:", error);
throw error;
}
};Flutter Database Service
// lib/services/database_service.dart
import 'package:appwrite/appwrite.dart';
import 'package:appwrite/models.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'appwrite_config.dart';
class DatabaseService {
// Get the Appwrite client from our config
final Client _client = getClient();
late final Databases _databases;
// Constructor initializes the Databases instance
DatabaseService() {
_databases = Databases(_client);
}
// List all documents/notes in the collection
Future<List<Document>> listDocuments({List<String>? queries}) async {
try {
// Fetch documents from the specified database and collection
final response = await _databases.listDocuments(
databaseId: dotenv.env['APPWRITE_DATABASE_ID']!,
collectionId: dotenv.env['APPWRITE_COLLECTION_ID']!,
queries: queries,
);
// Return the documents list from the response
return response.documents;
} catch (e) {
// Log and rethrow any errors
print('Error listing documents: $e');
throw e;
}
}
}6.6 Note Service
Now, let’s create a specialized service for our notes operations.
React Native Note Service
// src/services/note-service.js
import { Query } from "appwrite";
import { listDocuments } from "./database-service";
import client from "./appwrite-config";
import { Databases, ID } from "appwrite";
import { APPWRITE_DATABASE_ID, APPWRITE_COLLECTION_ID } from "@env";
const databases = new Databases(client);
// Get all notes, potentially filtered by userId
export const getNotes = async (userId = null) => {
try {
// Create query array - initially empty
const queries = [];
// If userId is provided, add a filter to only get notes for that user
if (userId) {
queries.push(Query.equal("userId", userId));
}
// Add sorting by createdAt in descending order (newest first)
queries.push(Query.orderDesc("createdAt"));
// Use the listDocuments function from database-service
const notes = await listDocuments(queries);
return notes;
} catch (error) {
console.error("Error getting notes:", error);
throw error;
}
};
// Create a new note
export const createNote = async (data) => {
try {
// Add timestamps to the note data
const noteData = {
...data,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// Create a document in the database
const response = await databases.createDocument(
APPWRITE_DATABASE_ID,
APPWRITE_COLLECTION_ID,
ID.unique(), // Generate a unique ID
noteData
);
return response;
} catch (error) {
console.error("Error creating note:", error);
throw error;
}
};
// Delete a note by ID
export const deleteNote = async (noteId) => {
try {
// Delete the document with the specified ID
await databases.deleteDocument(
APPWRITE_DATABASE_ID,
APPWRITE_COLLECTION_ID,
noteId
);
return true;
} catch (error) {
console.error("Error deleting note:", error);
throw error;
}
};
// Update an existing note
export const updateNote = async (noteId, data) => {
try {
// Add updated timestamp
const noteData = {
...data,
updatedAt: new Date().toISOString(),
};
// Update the document in the database
const response = await databases.updateDocument(
APPWRITE_DATABASE_ID,
APPWRITE_COLLECTION_ID,
noteId,
noteData
);
return response;
} catch (error) {
console.error("Error updating note:", error);
throw error;
}
};Flutter Note Service
// lib/services/note_service.dart
import 'package:appwrite/appwrite.dart';
import 'package:appwrite/models.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'appwrite_config.dart';
class NoteService {
final Client _client = getClient();
late final Databases _databases;
NoteService() {
_databases = Databases(_client);
}
// Get all notes, potentially filtered by userId
Future<List<Document>> getNotes({String? userId}) async {
try {
// Create query list - initially empty
List<String> queries = [];
// If userId is provided, add a filter
if (userId != null) {
queries.add(Query.equal('userId', userId));
}
// Add sorting by createdAt descending
queries.add(Query.orderDesc('createdAt'));
// Fetch documents from the database
final response = await _databases.listDocuments(
databaseId: dotenv.env['APPWRITE_DATABASE_ID']!,
collectionId: dotenv.env['APPWRITE_COLLECTION_ID']!,
queries: queries,
);
return response.documents;
} catch (e) {
print('Error getting notes: $e');
throw e;
}
}
// Create a new note
Future<Document> createNote(Map<String, dynamic> data) async {
try {
// Add timestamps to the note data
final noteData = {
...data,
'createdAt': DateTime.now().toIso8601String(),
'updatedAt': DateTime.now().toIso8601String(),
};
// Create a document in the database
final response = await _databases.createDocument(
databaseId: dotenv.env['APPWRITE_DATABASE_ID']!,
collectionId: dotenv.env['APPWRITE_COLLECTION_ID']!,
documentId: ID.unique(), // Generate a unique ID
data: noteData,
);
return response;
} catch (e) {
print('Error creating note: $e');
throw e;
}
}
// Delete a note by ID
Future<bool> deleteNote(String noteId) async {
try {
// Delete the document with the specified ID
await _databases.deleteDocument(
databaseId: dotenv.env['APPWRITE_DATABASE_ID']!,
collectionId: dotenv.env['APPWRITE_COLLECTION_ID']!,
documentId: noteId,
);
return true;
} catch (e) {
print('Error deleting note: $e');
throw e;
}
}
// Update an existing note
Future<Document> updateNote(String noteId, Map<String, dynamic> data) async {
try {
// Add updated timestamp
final noteData = {
...data,
'updatedAt': DateTime.now().toIso8601String(),
};
// Update the document in the database
final response = await _databases.updateDocument(
databaseId: dotenv.env['APPWRITE_DATABASE_ID']!,
collectionId: dotenv.env['APPWRITE_COLLECTION_ID']!,
documentId: noteId,
data: noteData,
);
return response;
} catch (e) {
print('Error updating note: $e');
throw e;
}
}
}6.7 Fetch Notes from Screen Component
Now let’s integrate our services with the UI to fetch and display notes.
React Native Notes Screen
// src/screens/NotesScreen.js
import React, { useState, useEffect } from "react";
import {
View,
Text,
FlatList,
StyleSheet,
ActivityIndicator,
} from "react-native";
import { getNotes } from "../services/note-service";
import NoteItem from "../components/NoteItem";
const NotesScreen = () => {
const [notes, setNotes] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// Function to fetch notes from the database
const fetchNotes = async () => {
try {
setLoading(true);
// Call the getNotes service function
const fetchedNotes = await getNotes();
// Update state with the fetched notes
setNotes(fetchedNotes);
} catch (err) {
console.error("Error fetching notes:", err);
setError("Failed to load notes. Please try again.");
} finally {
setLoading(false);
}
};
// Call the fetch function when component mounts
fetchNotes();
}, []); // Empty dependency array means this runs once on mount
// Show loading indicator while fetching data
if (loading) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" color="#0000ff" />
</View>
);
}
// Show error message if there was a problem
if (error) {
return (
<View style={styles.centered}>
<Text style={styles.errorText}>{error}</Text>
</View>
);
}
// Render the list of notes
return (
<View style={styles.container}>
<Text style={styles.title}>My Notes</Text>
<FlatList
data={notes}
keyExtractor={(item) => item.$id}
renderItem={({ item }) => <NoteItem note={item} />}
contentContainerStyle={styles.listContent}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 16,
backgroundColor: "#f5f5f5",
},
title: {
fontSize: 24,
fontWeight: "bold",
marginBottom: 16,
color: "#333",
},
listContent: {
paddingBottom: 20,
},
centered: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
errorText: {
color: "red",
fontSize: 16,
textAlign: "center",
},
});
export default NotesScreen;Flutter Notes Screen
// lib/screens/notes_screen.dart
import 'package:flutter/material.dart';
import 'package:appwrite/models.dart';
import '../services/note_service.dart';
import '../widgets/note_item.dart';
class NotesScreen extends StatefulWidget {
const NotesScreen({Key? key}) : super(key: key);
@override
_NotesScreenState createState() => _NotesScreenState();
}
class _NotesScreenState extends State<NotesScreen> {
final NoteService _noteService = NoteService();
List<Document> _notes = [];
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
// Fetch notes when the screen initializes
_fetchNotes();
}
// Function to fetch notes from the database
Future<void> _fetchNotes() async {
try {
setState(() {
_isLoading = true;
_error = null;
});
// Call the getNotes service function
final fetchedNotes = await _noteService.getNotes();
// Update state with the fetched notes
setState(() {
_notes = fetchedNotes;
_isLoading = false;
});
} catch (e) {
print('Error fetching notes: $e');
setState(() {
_error = 'Failed to load notes. Please try again.';
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
// Show loading indicator while fetching data
if (_isLoading) {
return const Center(
child: CircularProgressIndicator(),
);
}
// Show error message if there was a problem
if (_error != null) {
return Center(
child: Text(
_error!,
style: const TextStyle(color: Colors.red, fontSize: 16),
),
);
}
// Render the list of notes
return Scaffold(
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'My Notes',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(height: 16),
Expanded(
child: ListView.builder(
itemCount: _notes.length,
itemBuilder: (context, index) {
return NoteItem(note: _notes[index]);
},
),
),
],
),
),
);
}
}6.8 Add Note to Database
Now let’s implement the functionality to create new notes.
React Native Add Note
// src/components/AddNoteModal.js
import React, { useState } from "react";
import {
Modal,
View,
TextInput,
Button,
StyleSheet,
Text,
TouchableOpacity,
} from "react-native";
import { createNote } from "../services/note-service";
const AddNoteModal = ({ visible, onClose, onNoteAdded }) => {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
// Reset form state
const resetForm = () => {
setTitle("");
setContent("");
setError(null);
};
// Close modal and reset form
const handleClose = () => {
resetForm();
onClose();
};
// Save the new note
const handleSave = async () => {
// Basic form validation
if (!title.trim() || !content.trim()) {
setError("Please fill in both title and content");
return;
}
try {
setLoading(true);
setError(null);
// Prepare note data
const noteData = {
title: title.trim(),
content: content.trim(),
userId: "current-user-id", // We'll replace this with actual user ID later
};
// Call create note service
const newNote = await createNote(noteData);
// Reset form and close modal
resetForm();
onClose();
// Notify parent component about the new note
if (onNoteAdded) {
onNoteAdded(newNote);
}
} catch (err) {
console.error("Error creating note:", err);
setError("Failed to save note. Please try again.");
} finally {
setLoading(false);
}
};
return (
<Modal
visible={visible}
animationType="slide"
transparent={true}
onRequestClose={handleClose}
>
<View style={styles.centeredView}>
<View style={styles.modalView}>
<Text style={styles.modalTitle}>Add New Note</Text>
{error && <Text style={styles.errorText}>{error}</Text>}
<TextInput
style={styles.input}
placeholder="Title"
value={title}
onChangeText={setTitle}
/>
<TextInput
style={[styles.input, styles.contentInput]}
placeholder="Content"
value={content}
onChangeText={setContent}
multiline={true}
textAlignVertical="top"
/>
<View style={styles.buttonContainer}>
<TouchableOpacity
style={[styles.button, styles.cancelButton]}
onPress={handleClose}
disabled={loading}
>
<Text style={styles.buttonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.saveButton]}
onPress={handleSave}
disabled={loading}
>
<Text style={styles.buttonText}>
{loading ? "Saving..." : "Save Note"}
</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
centeredView: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
modalView: {
width: "90%",
backgroundColor: "white",
borderRadius: 10,
padding: 20,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
},
modalTitle: {
fontSize: 18,
fontWeight: "bold",
marginBottom: 16,
textAlign: "center",
},
input: {
borderWidth: 1,
borderColor: "#ddd",
borderRadius: 5,
padding: 10,
marginBottom: 15,
},
contentInput: {
height: 150,
},
buttonContainer: {
flexDirection: "row",
justifyContent: "space-between",
},
button: {
borderRadius: 5,
padding: 10,
elevation: 2,
minWidth: "45%",
alignItems: "center",
},
cancelButton: {
backgroundColor: "#ccc",
},
saveButton: {
backgroundColor: "#2196F3",
},
buttonText: {
color: "white",
fontWeight: "bold",
},
errorText: {
color: "red",
marginBottom: 10,
textAlign: "center",
},
});
export default AddNoteModal;Update the Notes Screen to include the modal:
// src/screens/NotesScreen.js (updated)
import React, { useState, useEffect } from "react";
import {
View,
Text,
FlatList,
StyleSheet,
ActivityIndicator,
TouchableOpacity,
} from "react-native";
import { getNotes } from "../services/note-service";
import NoteItem from "../components/NoteItem";
import AddNoteModal from "../components/AddNoteModal";
const NotesScreen = () => {
const [notes, setNotes] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [modalVisible, setModalVisible] = useState(false);
useEffect(() => {
fetchNotes();
}, []);
// Function to fetch notes from the database
const fetchNotes = async () => {
try {
setLoading(true);
const fetchedNotes = await getNotes();
setNotes(fetchedNotes);
} catch (err) {
console.error("Error fetching notes:", err);
setError("Failed to load notes. Please try again.");
} finally {
setLoading(false);
}
};
// Add the new note to the state and avoid refetching
const handleNoteAdded = (newNote) => {
setNotes((currentNotes) => [newNote, ...currentNotes]);
};
// Show loading indicator while fetching data
if (loading && notes.length === 0) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" color="#0000ff" />
</View>
);
}
// Show error message if there was a problem
if (error && notes.length === 0) {
return (
<View style={styles.centered}>
<Text style={styles.errorText}>{error}</Text>
</View>
);
}
// Render the list of notes
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>My Notes</Text>
<TouchableOpacity
style={styles.addButton}
onPress={() => setModalVisible(true)}
>
<Text style={styles.addButtonText}>+ Add Note</Text>
</TouchableOpacity>
</View>
<FlatList
data={notes}
keyExtractor={(item) => item.$id}
renderItem={({ item }) => <NoteItem note={item} />}
contentContainerStyle={styles.listContent}
refreshing={loading}
onRefresh={fetchNotes}
/>
<AddNoteModal
visible={modalVisible}
onClose={() => setModalVisible(false)}
onNoteAdded={handleNoteAdded}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 16,
backgroundColor: "#f5f5f5",
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 16,
},
title: {
fontSize: 24,
fontWeight: "bold",
color: "#333",
},
addButton: {
backgroundColor: "#2196F3",
paddingVertical: 8,
paddingHorizontal: 12,
borderRadius: 5,
},
addButtonText: {
color: "white",
fontWeight: "bold",
},
listContent: {
paddingBottom: 20,
},
centered: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
errorText: {
color: "red",
fontSize: 16,
textAlign: "center",
},
});
export default NotesScreen;Flutter Add Note
// lib/widgets/add_note_modal.dart
import 'package:flutter/material.dart';
import '../services/note_service.dart';
class AddNoteModal extends StatefulWidget {
final Function(Map<String, dynamic>) onNoteAdded;
const AddNoteModal({
Key? key,
required this.onNoteAdded,
}) : super(key: key);
@override
_AddNoteModalState createState() => _AddNoteModalState();
}
class _AddNoteModalState extends State<AddNoteModal> {
final _titleController = TextEditingController();
final _contentController = TextEditingController();
final _noteService = NoteService();
bool _isLoading = false;
String? _error;
@override
void dispose() {
_titleController.dispose();
_contentController.dispose();
super.dispose();
}
// Reset form state
void _resetForm() {
_titleController.clear();
_contentController.clear();
setState(() {
_error = null;
});
}
// Save the new note
Future<void> _handleSave() async {
// Basic form validation
final title = _titleController.text.trim();
final content = _contentController.text.trim();
if (title.isEmpty || content.isEmpty) {
setState(() {
_error = 'Please fill in both title and content';
});
return;
}
try {
setState(() {
_isLoading = true;
_error = null;
});
// Prepare note data
final noteData = {
'title': title,
'content': content,
'userId': 'current-user-id', // We'll replace this with actual user ID later
};
// Call create note service
final newNote = await _noteService.createNote(noteData);
// Reset form
_resetForm();
// Notify parent component and close modal
widget.onNoteAdded(newNote.data);
Navigator.pop(context);
} catch (e) {
print('Error creating note: $e');
setState(() {
_error = 'Failed to save note. Please try again.';
});
} finally {
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 0,
backgroundColor: Colors.transparent,
child: contentBox(context),
);
}
Widget contentBox(BuildContext context) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
shape: BoxShape.rectangle,
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children
:
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Add New Note',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
// Show error message if there is one
if (_error != null)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Text(
_error!,
style: const TextStyle(color: Colors.red),
textAlign: TextAlign.center,
),
),
// Title input field
TextField(
controller: _titleController,
decoration: const InputDecoration(
hintText: 'Title',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 15),
// Content input field
TextField(
controller: _contentController,
decoration: const InputDecoration(
hintText: 'Content',
border: OutlineInputBorder(),
),
maxLines: 5,
),
const SizedBox(height: 20),
// Buttons row
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Cancel button
TextButton(
onPressed: _isLoading ? null : () => Navigator.pop(context),
child: const Text('Cancel'),
),
// Save button
ElevatedButton(
onPressed: _isLoading ? null : _handleSave,
child: Text(_isLoading ? 'Saving...' : 'Save Note'),
),
],
),
],
),
);
}
}Update the Notes Screen to include the modal:
// lib/screens/notes_screen.dart (updated)
import 'package:flutter/material.dart';
import 'package:appwrite/models.dart';
import '../services/note_service.dart';
import '../widgets/note_item.dart';
import '../widgets/add_note_modal.dart';
class NotesScreen extends StatefulWidget {
const NotesScreen({Key? key}) : super(key: key);
@override
_NotesScreenState createState() => _NotesScreenState();
}
class _NotesScreenState extends State<NotesScreen> {
final NoteService _noteService = NoteService();
List<Document> _notes = [];
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
_fetchNotes();
}
// Function to fetch notes from the database
Future<void> _fetchNotes() async {
try {
setState(() {
_isLoading = true;
_error = null;
});
final fetchedNotes = await _noteService.getNotes();
setState(() {
_notes = fetchedNotes;
_isLoading = false;
});
} catch (e) {
print('Error fetching notes: $e');
setState(() {
_error = 'Failed to load notes. Please try again.';
_isLoading = false;
});
}
}
// Show the add note dialog
void _showAddNoteDialog() {
showDialog(
context: context,
builder: (context) => AddNoteModal(
onNoteAdded: _handleNoteAdded,
),
);
}
// Add the new note to the state and avoid refetching
void _handleNoteAdded(Map<String, dynamic> noteData) {
// Create a temporary Document object
final newNote = Document(
$id: noteData['\$id'] ?? 'temp-id',
$collectionId: 'notes',
$databaseId: 'NotesDB',
$createdAt: DateTime.now().toString(),
$updatedAt: DateTime.now().toString(),
$permissions: [],
data: noteData,
);
setState(() {
_notes = [newNote, ..._notes];
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with title and add button
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'My Notes',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
ElevatedButton(
onPressed: _showAddNoteDialog,
child: const Text('+ Add Note'),
),
],
),
const SizedBox(height: 16),
// Show loading indicator
if (_isLoading && _notes.isEmpty)
const Center(child: CircularProgressIndicator()),
// Show error message
if (_error != null && _notes.isEmpty)
Center(
child: Text(
_error!,
style: const TextStyle(color: Colors.red, fontSize: 16),
),
),
// Show the notes list
if (!_isLoading || _notes.isNotEmpty)
Expanded(
child: RefreshIndicator(
onRefresh: _fetchNotes,
child: ListView.builder(
itemCount: _notes.length,
itemBuilder: (context, index) {
return NoteItem(note: _notes[index]);
},
),
),
),
],
),
),
);
}
}6.9 Delete Notes
Let’s implement the functionality to delete notes.
React Native Delete Note
Update the NoteItem component:
// src/components/NoteItem.js
import React, { useState } from "react";
import { View, Text, StyleSheet, TouchableOpacity, Alert } from "react-native";
import { deleteNote } from "../services/note-service";
const NoteItem = ({ note, onNoteDeleted }) => {
const [deleting, setDeleting] = useState(false);
// Format the date for display
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toLocaleDateString();
};
// Handle delete confirmation and execution
const handleDelete = () => {
// Show confirmation dialog
Alert.alert("Delete Note", "Are you sure you want to delete this note?", [
{
text: "Cancel",
style: "cancel",
},
{
text: "Delete",
style: "destructive",
onPress: async () => {
try {
setDeleting(true);
// Call the deleteNote service function
await deleteNote(note.$id);
// Notify parent component if callback exists
if (onNoteDeleted) {
onNoteDeleted(note.$id);
}
} catch (error) {
console.error("Error deleting note:", error);
Alert.alert("Error", "Failed to delete note. Please try again.");
} finally {
setDeleting(false);
}
},
},
]);
};
return (
<View style={styles.container}>
<View style={styles.content}>
<Text style={styles.title}>{note.title}</Text>
<Text style={styles.date}>
Last updated: {formatDate(note.updatedAt)}
</Text>
<Text style={styles.noteContent} numberOfLines={3}>
{note.content}
</Text>
</View>
<TouchableOpacity
style={styles.deleteButton}
onPress={handleDelete}
disabled={deleting}
>
<Text style={styles.deleteText}>Delete</Text>
</TouchableOpacity>
</View>
);
};
const styles = StyleSheet.create({
container: {
backgroundColor: "white",
borderRadius: 8,
padding: 16,
marginBottom: 12,
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.2,
shadowRadius: 2,
elevation: 2,
flexDirection: "row",
},
content: {
flex: 1,
},
title: {
fontSize: 18,
fontWeight: "bold",
marginBottom: 4,
},
date: {
fontSize: 12,
color: "#666",
marginBottom: 8,
},
noteContent: {
fontSize: 14,
color: "#333",
},
deleteButton: {
justifyContent: "center",
paddingLeft: 12,
},
deleteText: {
color: "red",
fontWeight: "500",
},
});
export default NoteItem;Update the Notes Screen to handle note deletion:
// src/screens/NotesScreen.js (updated for delete)
// ... previous imports ...
const NotesScreen = () => {
// ... previous state variables ...
// Handle note deletion by removing it from state
const handleNoteDeleted = (noteId) => {
setNotes((currentNotes) =>
currentNotes.filter((note) => note.$id !== noteId)
);
};
// ... previous code ...
// Render the list of notes
return (
<View style={styles.container}>
{/* ... header code ... */}
<FlatList
data={notes}
keyExtractor={(item) => item.$id}
renderItem={({ item }) => (
<NoteItem note={item} onNoteDeleted={handleNoteDeleted} />
)}
contentContainerStyle={styles.listContent}
refreshing={loading}
onRefresh={fetchNotes}
/>
{/* ... AddNoteModal code ... */}
</View>
);
};
// ... styles ...
export default NotesScreen;Flutter Delete Note
Update the NoteItem widget:
// lib/widgets/note_item.dart
import 'package:flutter/material.dart';
import 'package:appwrite/models.dart';
import '../services/note_service.dart';
class NoteItem extends StatefulWidget {
final Document note;
final Function(String)? onNoteDeleted;
const NoteItem({
Key? key,
required this.note,
this.onNoteDeleted,
}) : super(key: key);
@override
_NoteItemState createState() => _NoteItemState();
}
class _NoteItemState extends State<NoteItem> {
final NoteService _noteService = NoteService();
bool _isDeleting = false;
// Format the date for display
String _formatDate(String dateString) {
final date = DateTime.parse(dateString);
return '${date.day}/${date.month}/${date.year}';
}
// Handle delete confirmation and execution
Future<void> _handleDelete() async {
// Show confirmation dialog
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Note'),
content: const Text('Are you sure you want to delete this note?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text(
'Delete',
style: TextStyle(color: Colors.red),
),
),
],
),
);
// If user confirmed deletion
if (confirmed == true) {
try {
setState(() {
_isDeleting = true;
});
// Call the deleteNote service function
await _noteService.deleteNote(widget.note.$id);
// Notify parent component if callback exists
if (widget.onNoteDeleted != null) {
widget.onNoteDeleted!(widget.note.$id);
}
} catch (e) {
print('Error deleting note: $e');
// Show error snackbar
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Failed to delete note. Please try again.'),
backgroundColor: Colors.red,
),
);
} finally {
if (mounted) {
setState(() {
_isDeleting = false;
});
}
}
}
}
@override
Widget build(BuildContext context) {
// Extract note data
final title = widget.note.data['title'] as String;
final content = widget.note.data['content'] as String;
final updatedAt = widget.note.$updatedAt;
return Card(
elevation: 2,
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
// Note content
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'Last updated: ${_formatDate(updatedAt)}',
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
const SizedBox(height: 8),
Text(
content,
style: const TextStyle(fontSize: 14),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
],
),
),
// Delete button
IconButton(
icon: _isDeleting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.delete, color: Colors.red),
onPressed: _isDeleting ? null : _handleDelete,
),
],
),
),
);
}
}Update the Notes Screen to handle note deletion:
// lib/screens/notes_screen.dart (updated for delete)
// ... previous imports ...
class _NotesScreenState extends State<NotesScreen> {
// ... previous state variables and methods ...
// Handle note deletion by removing it from state
void _handleNoteDeleted(String noteId) {
setState(() {
_notes = _notes.where((note) => note.$id != noteId).toList();
});
}
@override
Widget build(BuildContext context) {
// ... previous code ...
// Show the notes list
if (!_isLoading || _notes.isNotEmpty)
Expanded(
child: RefreshIndicator(
onRefresh: _fetchNotes,
child: ListView.builder(
itemCount: _notes.length,
itemBuilder: (context, index) {
return NoteItem(
note: _notes[index],
onNoteDeleted: _handleNoteDeleted,
);
},
),
),
),
// ... rest of the build method ...
}
}6.10 Update Notes
Now let’s implement the functionality to update existing notes.
React Native Update Note
Create an EditNoteModal component:
// src/components/EditNoteModal.js
import React, { useState, useEffect } from "react";
import {
Modal,
View,
TextInput,
Button,
StyleSheet,
Text,
TouchableOpacity,
} from "react-native";
import { updateNote } from "../services/note-service";
const EditNoteModal = ({ visible, onClose, onNoteUpdated, note }) => {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
// Initialize form with note data when it changes
useEffect(() => {
if (note) {
setTitle(note.title || "");
setContent(note.content || "");
}
}, [note]);
// Reset form state
const resetForm = () => {
setError(null);
};
// Close modal and reset form
const handleClose = () => {
resetForm();
onClose();
};
// Save the updated note
const handleSave = async () => {
// Basic form validation
if (!title.trim() || !content.trim()) {
setError("Please fill in both title and content");
return;
}
try {
setLoading(true);
setError(null);
// Prepare update data
const updateData = {
title: title.trim(),
content: content.trim(),
};
// Call update note service
const updatedNote = await updateNote(note.$id, updateData);
// Reset form and close modal
resetForm();
onClose();
// Notify parent component about the updated note
if (onNoteUpdated) {
onNoteUpdated(updatedNote);
}
} catch (err) {
console.error("Error updating note:", err);
setError("Failed to update note. Please try again.");
} finally {
setLoading(false);
}
};
// Don't render if no note is provided
if (!note) return null;
return (
<Modal
visible={visible}
animationType="slide"
transparent={true}
onRequestClose={handleClose}
>
<View style={styles.centeredView}>
<View style={styles.modalView}>
<Text style={styles.modalTitle}>Edit Note</Text>
{error && <Text style={styles.errorText}>{error}</Text>}
<TextInput
style={styles.input}
placeholder="Title"
value={title}
onChangeText={setTitle}
/>
<TextInput
style={[styles.input, styles.contentInput]}
placeholder="Content"
value={content}
onChangeText={setContent}
multiline={true}
textAlignVertical="top"
/>
<View style={styles.buttonContainer}>
<TouchableOpacity
style={[styles.button, styles.cancelButton]}
onPress={handleClose}
disabled={loading}
>
<Text style={styles.buttonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.saveButton]}
onPress={handleSave}
disabled={loading}
>
<Text style={styles.buttonText}>
{loading ? "Saving..." : "Save Changes"}
</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
// Same styles as AddNoteModal
centeredView: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
modalView: {
width: "90%",
backgroundColor: "white",
borderRadius: 10,
padding: 20,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
},
modalTitle: {
fontSize: 18,
fontWeight: "bold",
marginBottom: 16,
textAlign: "center",
},
input: {
borderWidth: 1,
borderColor: "#ddd",
borderRadius: 5,
padding: 10,
marginBottom: 15,
},
contentInput: {
height: 150,
},
buttonContainer: {
flexDirection: "row",
justifyContent: "space-between",
},
button: {
borderRadius: 5,
padding: 10,
elevation: 2,
minWidth: "45%",
alignItems: "center",
},
cancelButton: {
backgroundColor: "#ccc",
},
saveButton: {
backgroundColor: "#2196F3",
},
buttonText: {
color: "white",
fontWeight: "bold",
},
errorText: {
color: "red",
marginBottom: 10,
textAlign: "center",
},
});
export default EditNoteModal;Update the NoteItem component to include edit functionality:
// src/components/NoteItem.js (updated for edit)
import React, { useState } from "react";
import { View, Text, StyleSheet, TouchableOpacity, Alert } from "react-native";
import { deleteNote } from "../services/note-service";
import EditNoteModal from "./EditNoteModal";
const NoteItem = ({ note, onNoteDeleted, onNoteUpdated }) => {
const [deleting, setDeleting] = useState(false);
const [editModalVisible, setEditModalVisible] = useState(false);
// Format the date for display
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toLocaleDateString();
};
// Handle delete confirmation and execution
const handleDelete = () => {
// Same as before
// ...
};
// Handle opening the edit modal
const handleEdit = () => {
setEditModalVisible(true);
};
// Handle when a note is updated
const handleNoteUpdated = (updatedNote) => {
if (onNoteUpdated) {
onNoteUpdated(updatedNote);
}
};
return (
<View style={styles.container}>
<TouchableOpacity style={styles.content} onPress={handleEdit}>
<Text style={styles.title}>{note.title}</Text>
<Text style={styles.date}>
Last updated: {formatDate(note.updatedAt)}
</Text>
<Text style={styles.noteContent} numberOfLines={3}>
{note.content}
</Text>
</TouchableOpacity>
<View style={styles.buttonContainer}>
<TouchableOpacity style={styles.editButton} onPress={handleEdit}>
<Text style={styles.editText}>Edit</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.deleteButton}
onPress={handleDelete}
disabled={deleting}
>
<Text style={styles.deleteText}>Delete</Text>
</TouchableOpacity>
</View>
<EditNoteModal
visible={editModalVisible}
onClose={() => setEditModalVisible(false)}
onNoteUpdated={handleNoteUpdated}
note={note}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
backgroundColor: "white",
borderRadius: 8,
padding: 16,
marginBottom: 12,
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.2,
shadowRadius: 2,
elevation: 2,
},
content: {
flex: 1,
},
title: {
fontSize: 18,
fontWeight: "bold",
marginBottom: 4,
},
date: {
fontSize: 12,
color: "#666",
marginBottom: 8,
},
noteContent: {
fontSize: 14,
color: "#333",
},
buttonContainer: {
flexDirection: "row",
justifyContent: "flex-end",
marginTop: 12,
borderTopWidth: 1,
borderTopColor: "#eee",
paddingTop: 8,
},
editButton: {
marginRight: 16,
},
editText: {
color: "#2196F3",
fontWeight: "500",
},
deleteButton: {},
deleteText: {
color: "red",
fontWeight: "500",
},
});
export default NoteItem;Update the Notes Screen to handle note updates:
// src/screens/NotesScreen.js (updated for edit)
// ... previous imports ...
const NotesScreen = () => {
// ... previous state variables ...
// Handle note deletion by removing it from state
const handleNoteDeleted = (noteId) => {
setNotes((currentNotes) =>
currentNotes.filter((note) => note.$id !== noteId)
);
};
// Handle note update by updating it in state
const handleNoteUpdated = (updatedNote) => {
setNotes((currentNotes) =>
currentNotes.map((note) =>
note.$id === updatedNote.$id ? updatedNote : note
)
);
};
// ... previous code ...
// Render the list of notes
return (
<View style={styles.container}>
{/* ... header code ... */}
<FlatList
data={notes}
keyExtractor={(item) => item.$id}
renderItem={({ item }) => (
<NoteItem
note={item}
onNoteDeleted={handleNoteDeleted}
onNoteUpdated={handleNoteUpdated}
/>
)}
contentContainerStyle={styles.listContent}
refreshing={loading}
onRefresh={fetchNotes}
/>
{/* ... AddNoteModal code ... */}
</View>
);
};
// ... styles ...
export default NotesScreen;Flutter Update Note
Create an EditNoteModal widget:
// lib/widgets/edit_note_modal.dart
import 'package:flutter/material.dart';
import 'package:appwrite/models.dart';
import '../services/note_service.dart';
class EditNoteModal extends StatefulWidget {
final Document note;
final Function(Document) onNoteUpdated;
const EditNoteModal({
Key? key,
required this.note,
required this.onNoteUpdated,
}) : super(key: key);
@override
_EditNoteModalState createState() => _EditNoteModalState();
}
class _EditNoteModalState extends State<EditNoteModal> {
late TextEditingController _titleController;
late TextEditingController _contentController;
final _noteService = NoteService();
bool _isLoading = false;
String? _error;
@override
void initState() {
super.initState();
// Initialize text controllers with note data
_titleController = TextEditingController(text: widget.note.data['title']);
_contentController = TextEditingController(text: widget.note.data['content']);
}
@override
void dispose() {
_titleController.dispose();
_contentController.dispose();
super.dispose();
}
// Reset form error state
void _resetForm() {
setState(() {
_error = null;
});
}
// Save the updated note
Future<void> _handleSave() async {
// Basic form validation
final title = _titleController.text.trim();
final content = _contentController.text.trim();
if (title.isEmpty || content.isEmpty) {
setState(() {
_error = 'Please fill in both title and content';
});
return;
}
try {
setState(() {
_isLoading = true;
_error = null;
});
// Prepare update data
final updateData = {
'title': title,
'content': content,
};
// Call update note service
final updatedNote = await _noteService.updateNote(widget.note.$id, updateData);
// Reset form
_resetForm();
// Notify parent component and close modal
widget.onNoteUpdated(updatedNote);
Navigator.pop(context);
} catch (e) {
print('Error updating note: $e');
setState(() {
_error = 'Failed to update note. Please try again.';
});
} finally {
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 0,
backgroundColor: Colors.transparent,
child: contentBox(context),
);
}
Widget contentBox(BuildContext context) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
shape: BoxShape.rectangle,
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Edit Note',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
// Show error message if there is one
if (_error != null)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Text(
_error!,
style: const TextStyle(color: Colors.red),
textAlign: TextAlign.center,
),
),
// Title input field
TextField(
controller: _titleController,
decoration: const InputDecoration(
hintText: 'Title',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 15),
// Content input field
TextField(
controller: _contentController,
decoration: const InputDecoration(
hintText: 'Content',
border: OutlineInputBorder(),
),
maxLines: 5,
),
const SizedBox(height: 20),
// Buttons row
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Cancel button
TextButton(
onPressed: _isLoading ? null : () => Navigator.pop(context),
child: const Text('Cancel'),
),
// Save button
ElevatedButton(
onPressed: _isLoading ? null : _handleSave,
child: Text(_isLoading ? 'Saving...' : 'Save Changes'),
),
],
),
],
),
);
}
}Update the NoteItem widget for edit functionality:
// lib/widgets/note_item.dart (updated for edit)
import 'package:flutter/material.dart';
import 'package:appwrite/models.dart';
import '../services/note_service.dart';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