Getting Started with Directus and Flutter
Published May 2nd, 2024
Before You Start
You will need:
- Flutter SDK: Follow the official Flutter installation guide for your operating system (Windows, macOS, or Linux). This will also install the Dart programming language, which is required for Flutter development.
- A Directus project - follow our quickstart guide if you don't already have one.
- A code editor installed.
- Knowledge of Dart.
Initialize Project
On your terminal, navigate to the directory where you want to create your project, and run the following command:
flutter create my_directus_app
flutter create my_directus_app
Navigate to the project directory, after the project has been created and run the application with the command:
cd my_directus_app && flutter run
cd my_directus_app && flutter run
This will launch the app on an emulator or connected device. If everything is set up correctly, you should see the default Flutter app running.
Add these dependencies to your pubspec.yaml
file under the dependencies section:
dependencies:
http: ^0.13.5
flutter_dotenv: ^5.0.2
flutter_html: ^3.0.0-alpha.6
dependencies:
http: ^0.13.5
flutter_dotenv: ^5.0.2
flutter_html: ^3.0.0-alpha.6
Create an .env
file in an assets
directory of your Flutter project. This file will store your Directus Project URL.
DIRECTUS_API_URL=https://your-directus-project.com
DIRECTUS_API_URL=https://your-directus-project.com
In your main.dart
file, import the flutter_dotenv
package and load the environment variables:
import 'package:flutter_dotenv/flutter_dotenv.dart';
Future main() async {
await dotenv.load(fileName: ".env");
runApp(MyApp());
}
import 'package:flutter_dotenv/flutter_dotenv.dart';
Future main() async {
await dotenv.load(fileName: ".env");
runApp(MyApp());
}
Using Global Metadata and Settings
In your Directus project, navigate to Settings -> Data Model and create a new collection called global
. Under the Singleton option, select 'Treat as a single object', as this collection will have just a single entry containing global website metadata.
Create two text input fields - one with the key of title
and one description
.
Navigate to the content module and enter the global collection. Collections will generally display a list of items, but as a singleton, it will launch directly into the one-item form. Enter information in the title and description field and hit save.
By default, new collections are not accessible to the public. Navigate to Settings -> Access Control -> Public and give Read access to the Global collection.
Set up a DirectusService
class to retrieve all the global settings and use them in your project. Create a new file named directus_service.dart
in your lib
directory and add the code snippets:
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter_dotenv/flutter_dotenv.dart';
class DirectusService {
final String _baseUrl = dotenv.env['DIRECTUS_API_URL']!;
Future<Map<String, dynamic>> getGlobalMetadata() async {
final response = await http.get(Uri.parse('$_baseUrl/global'));
if (response.statusCode == 200) {
return jsonDecode(response.body)['data'];
} else {
throw Exception('Failed to load global metadata');
}
}
}
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter_dotenv/flutter_dotenv.dart';
class DirectusService {
final String _baseUrl = dotenv.env['DIRECTUS_API_URL']!;
Future<Map<String, dynamic>> getGlobalMetadata() async {
final response = await http.get(Uri.parse('$_baseUrl/global'));
if (response.statusCode == 200) {
return jsonDecode(response.body)['data'];
} else {
throw Exception('Failed to load global metadata');
}
}
}
The above code creates a method to fetch the global metadata settings from Directus.
Import the DirectusService
class and use it to retrieve global settings and metadata:
import 'package:flutter/material.dart';
import 'services/directus_service.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
Future main() async {
await dotenv.load(fileName: ".env");
runApp(MyApp());
}
class MyApp extends StatelessWidget {
final DirectusService _directusService = DirectusService();
MyApp({super.key});
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: Future.wait([
_directusService.getGlobalMetadata(),
]),
builder: (context,
AsyncSnapshot<List<Map<String, dynamic>>> settingsSnapshot) {
if (settingsSnapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
} else if (settingsSnapshot.hasError) {
return Text('Error: ${settingsSnapshot.error}');
} else {
final metadata = settingsSnapshot.data![0];
return MaterialApp(
title: metadata['title'],
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: Scaffold(
appBar: AppBar(
title: Text(metadata['title'] ?? 'My App'),
),
body: Center(
child:
Text(metadata['description'] ?? 'No description provided'),
),
),
);
}
},
);
}
}
import 'package:flutter/material.dart';
import 'services/directus_service.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
Future main() async {
await dotenv.load(fileName: ".env");
runApp(MyApp());
}
class MyApp extends StatelessWidget {
final DirectusService _directusService = DirectusService();
MyApp({super.key});
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: Future.wait([
_directusService.getGlobalMetadata(),
]),
builder: (context,
AsyncSnapshot<List<Map<String, dynamic>>> settingsSnapshot) {
if (settingsSnapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
} else if (settingsSnapshot.hasError) {
return Text('Error: ${settingsSnapshot.error}');
} else {
final metadata = settingsSnapshot.data![0];
return MaterialApp(
title: metadata['title'],
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: Scaffold(
appBar: AppBar(
title: Text(metadata['title'] ?? 'My App'),
),
body: Center(
child:
Text(metadata['description'] ?? 'No description provided'),
),
),
);
}
},
);
}
}
This will use the FutureBuilder
to fetch the global metadata from Directus. Once the data is loaded, you will use it throughout your application for the app title
, and description
of your application.
Creating Pages With Directus
Create a new collection called pages - make the Primary ID Field a "Manually Entered String" called slug, which will correlate with the URL for the page. For example about will later correlate to the page localhost:3000/about.
Create a text input field called title
and a WYSIWYG input field called content
. In Access Control, give the Public role read access to the new collection. Create 3 items in the new collection - here's some sample data.
Add a new method to fetch pages in your DirectusService
class from Directus in the directus_service.dart
file:
...
Future<Map<String, dynamic>> getPages() async {
final response = await http.get(Uri.parse('$_baseUrl/pages'));
if (response.statusCode == 200) {
return jsonDecode(response.body)['data'];
} else {
throw Exception('Failed to load pages');
}
}
...
...
Future<Map<String, dynamic>> getPages() async {
final response = await http.get(Uri.parse('$_baseUrl/pages'));
if (response.statusCode == 200) {
return jsonDecode(response.body)['data'];
} else {
throw Exception('Failed to load pages');
}
}
...
Create a page widget to display a single page using the data returned from the page collection. Create a screens
directory in the lib
directory. In the screens
directory, create a home_screen.dart
file and add the following code snippet:
import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart';
class PageWidget extends StatelessWidget {
final Map<String, dynamic> page;
const PageWidget({
super.key,
required this.page,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(page['title']),
),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
Html(
data: page['content'],
),
],
),
),
),
);
}
}
import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart';
class PageWidget extends StatelessWidget {
final Map<String, dynamic> page;
const PageWidget({
super.key,
required this.page,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(page['title']),
),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
Html(
data: page['content'],
),
],
),
),
),
);
}
}
This will render the content of your pages
collection and use the flutter_html
package to convert the WYSIWYG content to HTML. Update the the code in your main.dart
file to use the page widget:
import 'package:flutter/material.dart';
import 'services/directus_service.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'screens/home_screen.dart';
Future main() async {
await dotenv.load(fileName: ".env");
runApp(MyApp());
}
class MyApp extends StatelessWidget {
final DirectusService _directusService = DirectusService();
MyApp({super.key});
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: Future.wait([
_directusService.getGlobalMetadata(),
]),
builder: (context,
AsyncSnapshot<List<Map<String, dynamic>>> settingsSnapshot) {
if (settingsSnapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
} else if (settingsSnapshot.hasError) {
return Text('Error: ${settingsSnapshot.error}');
} else {
final metadata = settingsSnapshot.data![0];
return MaterialApp(
title: metadata['title'],
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: FutureBuilder<Map<String, dynamic>>(
future: _directusService.getPages(),
builder: (context, pagesSnapshot) {
if (pagesSnapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
} else if (pagesSnapshot.hasError) {
return Text('Error: ${pagesSnapshot.error}');
} else {
final pages = pagesSnapshot.data!;
return pages.isNotEmpty
? PageWidget(
page: pages,
)
: const Text('No pages found');
}
},
),
);
}
},
);
}
}
import 'package:flutter/material.dart';
import 'services/directus_service.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'screens/home_screen.dart';
Future main() async {
await dotenv.load(fileName: ".env");
runApp(MyApp());
}
class MyApp extends StatelessWidget {
final DirectusService _directusService = DirectusService();
MyApp({super.key});
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: Future.wait([
_directusService.getGlobalMetadata(),
]),
builder: (context,
AsyncSnapshot<List<Map<String, dynamic>>> settingsSnapshot) {
if (settingsSnapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
} else if (settingsSnapshot.hasError) {
return Text('Error: ${settingsSnapshot.error}');
} else {
final metadata = settingsSnapshot.data![0];
return MaterialApp(
title: metadata['title'],
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: FutureBuilder<Map<String, dynamic>>(
future: _directusService.getPages(),
builder: (context, pagesSnapshot) {
if (pagesSnapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
} else if (pagesSnapshot.hasError) {
return Text('Error: ${pagesSnapshot.error}');
} else {
final pages = pagesSnapshot.data!;
return pages.isNotEmpty
? PageWidget(
page: pages,
)
: const Text('No pages found');
}
},
),
);
}
},
);
}
}
Creating Blog Posts With Directus
Similar to creating pages, you can also create and manage blog posts using Directus CMS. Create a new collection called authors
with a single text input field called name
. Add one or more authors to the collection.
Create another collection called posts
and add the following fields:
- slug: Primary key field, Manually entered string
- title: Text input field
- content: WYSIWYG input field
- image: Image relational field
- author: Many-to-one relational field with the related collection set to
authors
Add 3 items in the posts
collection - here's some sample data.
Create Blog Post Listing
Add a new method to your DirectusService
class to fetch blog posts from Directus:
...
Future<List<dynamic>> getBlogPosts() async {
final response = await http.get(Uri.parse('$_baseUrl/posts'));
if (response.statusCode == 200) {
return jsonDecode(response.body)['data'];
} else {
throw Exception('Failed to load blog posts');
}
}
...
...
Future<List<dynamic>> getBlogPosts() async {
final response = await http.get(Uri.parse('$_baseUrl/posts'));
if (response.statusCode == 200) {
return jsonDecode(response.body)['data'];
} else {
throw Exception('Failed to load blog posts');
}
}
...
Update the code in your lib/screens/home_screen.dart
file to render the blog posts in the PageWidget
:
import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart';
class PageWidget extends StatelessWidget {
final Map<String, dynamic> pages;
final List<dynamic> blogPosts;
const PageWidget({
super.key,
required this.pages,
required this.blogPosts,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(pages['title']),
),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
Html(
data: pages['content'],
),
const SizedBox(height: 32),
Text(
'Blog Posts',
style: Theme.of(context).textTheme.headline6,
),
const SizedBox(height: 16),
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: blogPosts.length,
itemBuilder: (context, index) {
final blogPost = blogPosts[index];
return BlogPostItem(blogPost: blogPost);
},
),
],
),
),
),
);
}
}
class BlogPostItem extends StatelessWidget {
final dynamic blogPost;
const BlogPostItem({
super.key,
required this.blogPost,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
blogPost['title'],
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 8),
Html(
data: blogPost['content'],
),
],
),
);
}
}`
import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart';
class PageWidget extends StatelessWidget {
final Map<String, dynamic> pages;
final List<dynamic> blogPosts;
const PageWidget({
super.key,
required this.pages,
required this.blogPosts,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(pages['title']),
),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
Html(
data: pages['content'],
),
const SizedBox(height: 32),
Text(
'Blog Posts',
style: Theme.of(context).textTheme.headline6,
),
const SizedBox(height: 16),
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: blogPosts.length,
itemBuilder: (context, index) {
final blogPost = blogPosts[index];
return BlogPostItem(blogPost: blogPost);
},
),
],
),
),
),
);
}
}
class BlogPostItem extends StatelessWidget {
final dynamic blogPost;
const BlogPostItem({
super.key,
required this.blogPost,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
blogPost['title'],
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 8),
Html(
data: blogPost['content'],
),
],
),
);
}
}`
The PageWidget
accepts blogPost
which are the blog posts from Directus as a required parameter. Update the code in your main.dart
file to pass it from the DirectusService
class instance:
import 'package:flutter/material.dart';
import 'services/directus_service.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'screens/home_screen.dart';
Future main() async {
await dotenv.load(fileName: ".env");
runApp(MyApp());
}
class MyApp extends StatelessWidget {
final DirectusService _directusService = DirectusService();
MyApp({super.key});
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: Future.wait([
_directusService.getGlobalMetadata(),
_directusService.getBlogPosts(),
]),
builder: (context, AsyncSnapshot<List<dynamic>> settingsSnapshot) {
if (settingsSnapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
} else if (settingsSnapshot.hasError) {
return Text('Error: ${settingsSnapshot.error}');
} else {
final metadata = settingsSnapshot.data![0];
final blogPosts = settingsSnapshot.data![1];
return MaterialApp(
title: metadata['title'],
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: FutureBuilder<Map<String, dynamic>>(
future: _directusService.getPages(),
builder: (context, pagesSnapshot) {
if (pagesSnapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
} else if (pagesSnapshot.hasError) {
return Text('Error: ${pagesSnapshot.error}');
} else {
final pages = pagesSnapshot.data!;
return pages.isNotEmpty
? PageWidget(pages: pages, blogPosts: blogPosts)
: const Text('No pages found');
}
},
),
);
}
},
);
}
}
import 'package:flutter/material.dart';
import 'services/directus_service.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'screens/home_screen.dart';
Future main() async {
await dotenv.load(fileName: ".env");
runApp(MyApp());
}
class MyApp extends StatelessWidget {
final DirectusService _directusService = DirectusService();
MyApp({super.key});
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: Future.wait([
_directusService.getGlobalMetadata(),
_directusService.getBlogPosts(),
]),
builder: (context, AsyncSnapshot<List<dynamic>> settingsSnapshot) {
if (settingsSnapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
} else if (settingsSnapshot.hasError) {
return Text('Error: ${settingsSnapshot.error}');
} else {
final metadata = settingsSnapshot.data![0];
final blogPosts = settingsSnapshot.data![1];
return MaterialApp(
title: metadata['title'],
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: FutureBuilder<Map<String, dynamic>>(
future: _directusService.getPages(),
builder: (context, pagesSnapshot) {
if (pagesSnapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
} else if (pagesSnapshot.hasError) {
return Text('Error: ${pagesSnapshot.error}');
} else {
final pages = pagesSnapshot.data!;
return pages.isNotEmpty
? PageWidget(pages: pages, blogPosts: blogPosts)
: const Text('No pages found');
}
},
),
);
}
},
);
}
}
Create Blog Post Detail
Create a new file called post_single.dart
file in the lib/screens
directory. Then create a BlogPostWidget
:
import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart';
class BlogPostWidget extends StatelessWidget {
final Map<String, dynamic> post;
const BlogPostWidget({super.key, required this.post });
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(post['title']),
),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
Html(
data: post['content'],
),
],
),
),
),
);
}
}
import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart';
class BlogPostWidget extends StatelessWidget {
final Map<String, dynamic> post;
const BlogPostWidget({super.key, required this.post });
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(post['title']),
),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
Html(
data: post['content'],
),
],
),
),
),
);
}
}
The BlogPostWidget
serves as the single blog post view. When a user clicks on a blog post from the listing, the app navigates to this widget, displaying the full content of the selected post.
Add Navigation
Update the BlogPostItem
class in the lib/screens/home_screen.dart
file to add navigation to the project:
class BlogPostItem extends StatelessWidget {
final dynamic blogPost;
const BlogPostItem({
super.key,
required this.blogPost,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => BlogPostWidget(post: blogPost),
),
);
},
child: Container(
margin: const EdgeInsets.only(bottom: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
blogPost['title'],
style: Theme.of(context).textTheme.labelLarge,
),
],
),
),
);
}
}
class BlogPostItem extends StatelessWidget {
final dynamic blogPost;
const BlogPostItem({
super.key,
required this.blogPost,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => BlogPostWidget(post: blogPost),
),
);
},
child: Container(
margin: const EdgeInsets.only(bottom: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
blogPost['title'],
style: Theme.of(context).textTheme.labelLarge,
),
],
),
),
);
}
}
With the above code, when the user taps on the BlogPostItem
, it triggers the onTap
callback function. Inside this function, the Navigator.push
will navigate to a new screen. MaterialPageRoute
will define the widget to be displayed on the new screen as BlogPostWidget
. Also, the blogPost
data is passed as a parameter to the BlogPostWidget
widget. This will allow you to display detailed information about the selected blog
post on the new screen.
Summary
Throughout this tutorial, you've learned how to build a Flutter application that uses data from a Directus project. You started by creating a new project, set up environment variables and everything you need to call Directus. You then created pages and posts collections in Directus and integrated them with the the Flutter.