Flutter - Database Concepts
Flutter - Modern Database Concepts
Section titled “Flutter - Modern Database Concepts”Flutter offers robust packages for interacting with various types of databases, both local and cloud-based. Key options include:
- sqflite: The standard package for accessing and manipulating local SQLite databases on the device.
- cloud_firestore: A package for interacting with Google’s flexible, scalable NoSQL cloud database, Firebase Firestore.
- Other options: Packages like
drift(formerly Moor) provide higher-level abstractions over SQLite/SQL, whilehiveoffers a fast key-value store. Realm also provides an official SDK for Flutter.
This chapter focuses on sqflite for local storage and cloud_firestore for cloud storage, demonstrating common patterns.
Local Storage with SQLite and sqflite
Section titled “Local Storage with SQLite and sqflite”SQLite is a time-tested, embedded SQL database engine ideal for storing structured data locally on a device. The sqflite package provides a low-level API to interact with SQLite databases effectively in Flutter.
Core functionalities provided by sqflite include:
- Opening or Creating a Database: Using
openDatabase, you can connect to an existing database file or create a new one. - Executing SQL Statements: Methods like
executeallow running raw SQL commands (CREATE TABLE, INSERT, UPDATE, DELETE). Use parameterized queries (?placeholders) to prevent SQL injection vulnerabilities. - Querying Data: The
querymethod provides a convenient way to fetch data using structured parameters (columns, where, orderBy, etc.). Raw queries can be performed usingrawQuery. - Transactions:
transactionensures multiple database operations are performed atomically (all succeed or all fail).
Let’s update the product application example to demonstrate storing and fetching product information using sqflite with modern practices.
- Create a new Flutter application:
flutter create product_sqlite_app. - Navigate into the project:
cd product_sqlite_app. - Add dependencies: Open
pubspec.yamland addsqfliteandpath_providerunderdependencies:
dependencies: flutter: sdk: flutter sqflite: ^2.3.0 # Use the latest stable version path_provider: ^2.1.1 # Use the latest stable version path: ^1.8.3 # Usually included transitively, but good to specify- Run
flutter pub getin your terminal to install the packages. - Define the Data Model: Create a
lib/product.dartfile with theProductclass. Ensure it uses null safety and has methods for converting to/from a Map, crucial for database interaction. Using an auto-incrementing primary key is recommended.
class Product { final int? id; // Nullable for new products before insertion final String name; final String description; final int price; final String image;
// Use const constructor for immutable objects const Product({ this.id, required this.name, required this.description, required this.price, required this.image, });
// Factory constructor to create a Product from a Map (e.g., from DB) factory Product.fromMap(Map<String, dynamic> map) { return Product( id: map['id'] as int?, name: map['name'] as String, description: map['description'] as String, price: map['price'] as int, image: map['image'] as String, ); }
// Method to convert a Product instance to a Map (e.g., for DB insertion/update) Map<String, dynamic> toMap() { // Exclude 'id' when null (for auto-increment insertion) return { if (id != null) 'id': id, 'name': name, 'description': description, 'price': price, 'image': image, }; }
// Optional: copyWith method for easier updates Product copyWith({ int? id, String? name, String? description, int? price, String? image, }) { return Product( id: id ?? this.id, name: name ?? this.name, description: description ?? this.description, price: price ?? this.price, image: image ?? this.image, ); }
@override String toString() { return 'Product(id: $id, name: $name, description: $description, price: $price, image: $image)'; }}- Create a Database Helper Service: Create a
lib/database_helper.dartfile. This class will encapsulate all database operations using the singleton pattern or dependency injection.
import 'dart:async';import 'package:path/path.dart';import 'package:sqflite/sqflite.dart';import 'package:path_provider/path_provider.dart';import 'product.dart';
class DatabaseHelper { // Singleton pattern static final DatabaseHelper _instance = DatabaseHelper._internal(); factory DatabaseHelper() => _instance; DatabaseHelper._internal();
static Database? _database; static const String _dbName = 'ProductDB.db'; static const String _tableName = 'Product'; static const int _dbVersion = 1;
Future<Database> get database async { if (_database != null) return _database!; _database = await _initDB(); return _database!; }
Future<Database> _initDB() async { final documentsDirectory = await getApplicationDocumentsDirectory(); final path = join(documentsDirectory.path, _dbName);
return await openDatabase( path, version: _dbVersion, onCreate: _onCreate, // onUpgrade: _onUpgrade, // Optional: Handle schema migrations here ); }
// Create table and insert initial data Future<void> _onCreate(Database db, int version) async { await db.execute(''' CREATE TABLE $_tableName ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, description TEXT NOT NULL, price INTEGER NOT NULL, image TEXT NOT NULL ) ''');
// Optional: Insert initial data using Batch for efficiency final batch = db.batch(); _getInitialProducts().forEach((product) { batch.insert(_tableName, product.toMap()); }); await batch.commit(noResult: true); }
List<Product> _getInitialProducts() { return [ Product(name: 'iPhone', description: 'The stylish phone', price: 1000, image: 'iphone.png'), Product(name: 'Pixel', description: 'The feature phone', price: 800, image: 'pixel.png'), Product(name: 'Laptop', description: 'Productive tool', price: 2000, image: 'laptop.png'), // ... add other initial products ]; }
// Insert a Product Future<int> insertProduct(Product product) async { final db = await database; // Use `insert` which handles auto-increment ID return await db.insert(_tableName, product.toMap(), conflictAlgorithm: ConflictAlgorithm.replace); }
// Get all Products Future<List<Product>> getAllProducts() async { final db = await database; final List<Map<String, dynamic>> maps = await db.query(_tableName, orderBy: 'id ASC');
return List.generate(maps.length, (i) { return Product.fromMap(maps[i]); }); }
// Get a single Product by ID Future<Product?> getProductById(int id) async { final db = await database; final List<Map<String, dynamic>> maps = await db.query( _tableName, where: 'id = ?', whereArgs: [id], limit: 1, // Expect only one result );
if (maps.isNotEmpty) { return Product.fromMap(maps.first); } else { return null; } }
// Update a Product Future<int> updateProduct(Product product) async { final db = await database; return await db.update( _tableName, product.toMap(), where: 'id = ?', whereArgs: [product.id], ); }
// Delete a Product Future<int> deleteProduct(int id) async { final db = await database; return await db.delete( _tableName, where: 'id = ?', whereArgs: [id], ); }
// Close the database (optional, useful in specific scenarios) Future<void> close() async { final db = await database; db.close(); _database = null; // Reset the static variable }}- Integrate with UI: Modify your
main.dart(or relevant widget files) to useDatabaseHelper. - Use
FutureBuilderor a state management solution (like Provider or Riverpod) to handle the asynchronous nature of database calls and update the UI accordingly.
// Example usage within a StatefulWidget's State or a StateNotifier/ChangeNotifier
// Get the helper instancefinal dbHelper = DatabaseHelper();
// Fetch productsFuture<List<Product>> _loadProducts() async { return await dbHelper.getAllProducts();}
// Add a new productFuture<void> _addProduct(Product newProduct) async { await dbHelper.insertProduct(newProduct); // Refresh UI (e.g., call setState or notifyListeners)}
// Update an existing productFuture<void> _updateProduct(Product updatedProduct) async { await dbHelper.updateProduct(updatedProduct); // Refresh UI}
// Delete a productFuture<void> _deleteProduct(int id) async { await dbHelper.deleteProduct(id); // Refresh UI}This setup provides a clean separation of concerns and uses modern Dart/Flutter practices for local database management.
Cloud Storage with Firebase Firestore
Section titled “Cloud Storage with Firebase Firestore”Firebase offers Backend-as-a-Service (BaaS) features, significantly speeding up app development. Firebase Firestore is a popular choice for a cloud-based, real-time NoSQL database that scales automatically and offers offline support.
The cloud_firestore package allows Flutter apps to interact directly with Firestore. Let’s update the example to connect to Firestore.
- Create a new Flutter application:
flutter create product_firestore_app. - Navigate into the project:
cd product_firestore_app. - Add Firebase Core and Firestore dependencies: Open
pubspec.yaml:
dependencies: flutter: sdk: flutter firebase_core: ^2.24.2 # Use latest stable version cloud_firestore: ^4.13.6 # Use latest stable version- Run
flutter pub get. - Configure Firebase: This is a crucial step and involves platform-specific setup.
- a. Install the Firebase CLI:
npm install -g firebase-tools(or other methods). - b. Log in:
firebase login. - c. Install the FlutterFire CLI:
dart pub global activate flutterfire_cli. - d. Configure your project: Run
flutterfire configurein your Flutter project’s root directory. This command will guide you through selecting or creating a Firebase project and automatically configure your Android, iOS, and potentially web apps, generating necessary files likefirebase_options.dartand updating native configuration files. - e. (Manual Fallback - Not Recommended): If CLI fails, follow the Firebase console instructions to manually add Android/iOS apps, download
google-services.json(Android) /GoogleService-Info.plist(iOS), and configure Gradle/Xcode. - Initialize Firebase in your app: Update
lib/main.dartto initialize Firebase before running the app.
import 'package:flutter/material.dart';import 'package:firebase_core/firebase_core.dart';import 'firebase_options.dart'; // Generated by flutterfire configure
void main() async { // Ensure Flutter bindings are initialized WidgetsFlutterBinding.ensureInitialized();
// Initialize Firebase await Firebase.initializeApp( options: DefaultFirebaseOptions.currentPlatform, );
runApp(MyApp());}
class MyApp extends StatelessWidget { // ... rest of your MyApp widget @override Widget build(BuildContext context) { return MaterialApp( // ... ); }}- Update the
Productclass (if needed): EnsureProduct.fromMapcan handle Firestore’s data types (e.g.,Timestampfor dates). Add atoJsonortoMapmethod suitable for Firestore.
import 'package:cloud_firestore/cloud_firestore.dart';
class Product { final String? id; // Firestore document ID final String name; final String description; final int price; final String image; // Use Timestamp for dates in Firestore final Timestamp? createdAt;
Product({ this.id, required this.name, required this.description, required this.price, required this.image, this.createdAt, });
// Convert Firestore DocumentSnapshot to Product object factory Product.fromFirestore(DocumentSnapshot<Map<String, dynamic>> snapshot, SnapshotOptions? options) { final data = snapshot.data(); return Product( id: snapshot.id, name: data?['name'] ?? '', description: data?['description'] ?? '', price: data?['price'] ?? 0, image: data?['image'] ?? '', createdAt: data?['createdAt'] as Timestamp?, ); }
// Convert Product object to Map for Firestore Map<String, dynamic> toFirestore() { return { 'name': name, 'description': description, 'price': price, 'image': image, // Use FieldValue for server-side timestamp on creation 'createdAt': createdAt ?? FieldValue.serverTimestamp(), }; }}- Interact with Firestore: Use
FirebaseFirestore.instanceto get data. - a. Get a reference to your collection:
FirebaseFirestore.instance.collection('products'). - b. Use
.withConverterfor type safety with yourProductmodel. - c. Fetch data using
get()(for single fetch) orsnapshots()(for real-time updates).
// Get a reference to the collection with a type converterfinal productsRef = FirebaseFirestore.instance .collection('products') .withConverter<Product>( fromFirestore: Product.fromFirestore, toFirestore: (Product product, _) => product.toFirestore(), );
// Fetch products onceFuture<List<Product>> fetchProductsOnce() async { try { final querySnapshot = await productsRef.orderBy('createdAt', descending: true).get(); return querySnapshot.docs.map((doc) => doc.data()).toList(); } catch (e) { print('Error fetching products: $e'); return []; }}
// Get a real-time stream of productsStream<QuerySnapshot<Product>> fetchProductsStream() { return productsRef.orderBy('createdAt', descending: true).snapshots();}
// Add a new productFuture<void> addProduct(Product newProduct) async { try { await productsRef.add(newProduct); print('Product added!'); } catch (e) { print('Error adding product: $e'); }}
// Update a product (requires document ID)Future<void> updateProduct(String docId, Product updatedProduct) async { try { await productsRef.doc(docId).update(updatedProduct.toFirestore()); print('Product updated!'); } catch (e) { print('Error updating product: $e'); }}
// Delete a product (requires document ID)Future<void> deleteProduct(String docId) async { try { await productsRef.doc(docId).delete(); print('Product deleted!'); } catch (e) { print('Error deleting product: $e'); }}- Use
StreamBuilderfor Real-time Updates: If usingsnapshots(), wrap your UI component (like aListView) in aStreamBuilderto automatically rebuild when data changes in Firestore.
@overrideWidget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text("Products from Firestore")), body: StreamBuilder<QuerySnapshot<Product>>( stream: fetchProductsStream(), // Your stream function builder: (context, snapshot) { if (snapshot.hasError) { return Center(child: Text('Error: ${snapshot.error}')); } if (snapshot.connectionState == ConnectionState.waiting) { return Center(child: CircularProgressIndicator()); } if (!snapshot.hasData || snapshot.data!.docs.isEmpty) { return Center(child: Text('No products found.')); }
// Data is available final products = snapshot.data!.docs.map((doc) => doc.data()).toList();
return ListView.builder( itemCount: products.length, itemBuilder: (context, index) { final product = products[index]; // Build your list item widget using product data return ListTile( title: Text(product.name), subtitle: Text(product.description), // ... add interactions like delete/edit ); }, ); }, ), floatingActionButton: FloatingActionButton( onPressed: () { // Example: Add a new product addProduct(Product(name: 'New Gadget', description: 'Latest tech', price: 500, image: 'gadget.png')); }, child: Icon(Icons.add), ), );}Firestore provides a powerful, scalable backend solution. Remember to configure Firestore security rules in the Firebase console to protect your data.