With the evolution of technology, mobile application development processes are also evolving. In this article, we will explore how using Flutter and Riverpod for Strapi API and GraphQL integration can simplify and even transform application development processes. The modern application development process with Flutter, Riverpod, and Strapi offers developers flexibility and scalability. With these technologies, you can create user-friendly and high-performance mobile applications. These aspects of application development play a critical role in the success of your project.
Before starting the tutorial on developing a personal target tracking application with Flutter, Riverpod, Strapi, and GraphQL, ensure you meet the following requirements:
In the realm of Flutter development, managing the state of an application can often become complex as the app grows. This is where Flutter Riverpod comes into play, offering a refined and advanced solution for state management that addresses the limitations of its predecessor, Provider. Here’s why Riverpod stands out:
GoalNotifier
to efficiently handle immutable state. Freezed complements Riverpod by enabling the use of immutable objects in Dart, which aids in making your state management more predictable and safer.Strapi, GraphQL, Flutter, and Riverpod create a cohesive development ecosystem that balances backend flexibility, efficient data management, cross-platform UI development, and robust state management. This combination is particularly potent for building modern, scalable, high-performance mobile applications requiring real-time data updates, custom content structures, and a smooth user experience across multiple platforms.
Before starting your project, it's essential to have your Flutter development environment properly set up. This requires having the Dart SDK and downloading Flutter directly from its official website (flutter.dev). To verify you are using the latest version of Flutter, run the flutter doctor command in your terminal.
flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.19.6, on macOS 14.4.1 23E224 darwin-arm64, locale en-DE)
[✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 15.3)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2023.1)
[✓] VS Code (version 1.88.1)
[✓] Connected device (3 available)
[✓] Network resources
Additionally, if you're using an advanced Integrated Development Environment (IDE) like Visual Studio Code (VSCode), you can directly use iOS or Android emulators through the IDE.
We named our project personal_goals_app
. This name reflects our aim to create an application where users can set personal goals. A clean Flutter setup and the establishment of a state management system with Riverpod greatly facilitate Strapi API integration.
Through the terminal or command prompt, run the command below to create a new Flutter project named personal_goals_app
.
flutter create personal_goals_app
Navigate to the created project directory by running the command below:
cd personal_goals_app
Start your application with the command below:
flutter run
This confirms that your first Flutter application is running successfully.
VSCode Command Palette:
VSCode Terminal:
The src/goals/components
and src/goals/provider
directories hold your UI components and state management logic, respectively. This separation makes your code more readable and manageable.
The src/goals
directory contains your Goal model and general files. The main.dart
file includes your application's navigation and basic settings.
State management is one of the cornerstones of modern application development. Riverpod stands out for its flexibility and ease of use in this area.
Navigate to your pubspec.yaml
file and add the following line under dependencies to include Riverpod in your project:
1dependencies:
2 flutter:
3 sdk: flutter
4
5 # The following adds the Cupertino Icons font to your application.
6 # Use with the CupertinoIcons class for iOS style icons.
7 cupertino_icons: ^1.0.2
8 flutter_riverpod: ^2.5.1
9 intl: ^0.18.0
Goal
ModelIn the goal_model.dart
file, define Goal
model. Use the Goal
class and GoalStatus
enum.
1enum GoalStatus { active, completed, pending }
2
3enum GoalCategory { vacation, money, exercise, smoke, language }
4
5class Goal {
6 final String id;
7 final String name;
8 final String description;
9 final DateTime startDate;
10 final DateTime?
11 endDate; // End date is optional because some goals might not have a specific end date
12 final GoalCategory category;
13 GoalStatus status;
14 double?
15 targetValue; // Numeric value representing the goal target (e.g., amount to save)
16 double?
17 currentValue; // Current progress towards the goal (e.g., current savings)
18
19 Goal({
20 required this.id,
21 required this.name,
22 required this.description,
23 required this.startDate,
24 this.endDate,
25 required this.category,
26 this.status = GoalStatus.pending,
27 this.targetValue,
28 this.currentValue,
29 });
30
31 // Calculate the status of the goal based on dates
32 static GoalStatus calculateStatus(DateTime startDate, DateTime endDate) {
33 final currentDate = DateTime.now();
34 if (currentDate.isAfter(endDate)) {
35 return GoalStatus.completed;
36 } else if (currentDate.isAfter(startDate)) {
37 return GoalStatus.active;
38 } else {
39 return GoalStatus.pending;
40 }
41 }
42}
Create a new file for your state, e.g., goal_state.dart
. Use Freezed to define an immutable state class. In this example, the state will directly hold a list of goals, but you could expand it to include other state properties as needed.
1import 'package:freezed_annotation/freezed_annotation.dart';
2import 'package:personal_goals_app/src/goals/models/goal_model.dart';
3part 'goal_state.freezed.dart';
4
5
6class GoalState with _$GoalState {
7 const factory GoalState({
8 ([]) List<Goal> goals,
9 }) = _GoalState;
10}
In the lib/src/providers
directory, create a file named goal_provider.dart
. In this file, set up a structure using StateNotifier
that allows you to add, update, and delete goals.
1import 'package:flutter_riverpod/flutter_riverpod.dart';
2import 'package:personal_goals_app/src/goals/models/goal_model.dart';
3import 'package:personal_goals_app/src/provider/goal_state.dart';
4
5class GoalNotifier extends StateNotifier<GoalState> {
6 GoalNotifier()
7 : super(GoalState(goals: [
8 Goal(
9 id: '1',
10 name: 'Vacation in Milan',
11 description: 'Enjoy the beauty of Milan',
12 startDate: DateTime(2024, 04, 29),
13 endDate: DateTime(2024, 11, 1),
14 category: GoalCategory.vacation,
15 status: GoalStatus.active,
16 ),
17 Goal(
18 id: '2',
19 name: 'Quit Smoking',
20 description:
21 'Reduce cigarette intake gradually and increase smoke-free days',
22 startDate: DateTime.now(),
23 endDate: DateTime.now().add(const Duration(days: 90)),
24 category: GoalCategory.smoke,
25 ),
26 ]));
27
28 // Add a new goal
29 void addGoal(Goal goal) {
30 state = state.copyWith(goals: [...state.goals, goal]);
31 }
32
33 // Update an existing goal
34 void updateGoal(String id, Goal updatedGoal) {
35 state = state.copyWith(
36 goals: state.goals
37 .map((goal) => goal.id == id ? updatedGoal : goal)
38 .toList(),
39 );
40 }
41
42 // Delete a goal
43 void deleteGoal(String id) {
44 state = state.copyWith(
45 goals: state.goals.where((goal) => goal.id != id).toList(),
46 );
47 }
48}
49
50final goalProvider = StateNotifierProvider<GoalNotifier, GoalState>((ref) {
51 return GoalNotifier();
52});
ProviderScope
:In the main.dart
file, which is the main entry point of your application, wrap your MaterialApp
widget with ProviderScope
to make Flutter Riverpod's state management system available throughout your application.
1void main() {
2 runApp(const ProviderScope(child: MyApp()));
3}
Flutter operates through the main.dart
file. In this file, you use the MaterialApp
widget to bring your application to life and start it with runApp
. Here, you can set up routing, define themes, and launch your homepage.
In the main.dart
file, set up the navigation logic that will manage your application's transitions between pages.
1void main() {
2 runApp(const ProviderScope(child: MyApp()));
3}
4
5class MyApp extends StatelessWidget {
6 const MyApp({super.key});
7
8
9 Widget build(BuildContext context) {
10 return MaterialApp(
11 title: 'Flutter Strapi Api Demo',
12 theme: ThemeData(
13 colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
14 useMaterial3: true,
15 ),
16 home: const HomePage(),
17 initialRoute: '/',
18 routes: {
19 '/start': (context) => StartGoal(),
20 '/add': (context) => const GoalFormPage(),
21 },
22 onGenerateRoute: (settings) {
23 if (settings.name == '/edit') {
24 final goal = settings.arguments as Goal;
25 return MaterialPageRoute(
26 builder: (context) {
27 return GoalEditPage(goal: goal);
28 },
29 );
30 }
31 return null;
32 },
33 );
34 }
35}
Now we need to expand the component structure in the src
directory. So home.dart
will list our goals. Creating detailed components like goal_add.dart
, goal_edit.dart
, goal_start.dart
, goal_card.dart
will make our work and state management easier as the project progresses.
Implement the Home page in home.dart
.
FloatingActionButton
to navigate to the “Add Goal” page.1class HomePage extends ConsumerWidget {
2 const HomePage({Key? key}) : super(key: key);
3
4
5 Widget build(BuildContext context, WidgetRef ref) {
6 final goals = ref.watch(goalProvider).goals;
7
8 return Scaffold(
9 appBar: AppBar(
10 title: const Text('Targets'),
11 ),
12 body: ListView.builder(
13 itemCount: goals.length,
14 itemBuilder: (context, index) {
15 final goal = goals[index];
16 return GoalCard(goal: goal);
17 },
18 ),
19 floatingActionButton: FloatingActionButton.extended(
20 onPressed: () {
21 Navigator.pushNamed(context, '/start');
22 },
23 label: const Text('Add New Target'),
24 icon: const Icon(Icons.add),
25 ),
26 );
27 }
28}
Create Goal cards in goal_card.dart
.
1class GoalCard extends StatelessWidget {
2 final Goal goal;
3
4 const GoalCard({Key? key, required this.goal}) : super(key: key);
5
6 String formatDate(DateTime date) {
7 return '${date.month}/${date.year}';
8 }
9
10 Color getStatusColor(GoalStatus status) {
11 switch (status) {
12 case GoalStatus.active:
13 return Colors.deepPurple;
14 case GoalStatus.pending:
15 return Colors.blue;
16 case GoalStatus.completed:
17 return Colors.green;
18 default:
19 return Colors.grey;
20 }
21 }
22
23
24 Widget build(BuildContext context) {
25 goal.status = Goal.calculateStatus(goal.startDate, goal.endDate!);
26
27 return Card(
28 margin: const EdgeInsets.all(24),
29 child: Column(
30 mainAxisSize: MainAxisSize.min,
31 children: <Widget>[
32 Container(
33 width: 120,
34 color: getStatusColor(goal.status),
35 padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16),
36 alignment: Alignment.center,
37 child: Text(
38 goal.status.toString().split('.').last.toUpperCase(),
39 style: const TextStyle(color: Colors.white),
40 ),
41 ),
42 ListTile(
43 leading: const Icon(Icons.track_changes),
44 title: Text(goal.name),
45 subtitle: Text(
46 'Target duration: ${goal.endDate?.difference(goal.startDate).inDays ?? 'N/A'} days',
47 ),
48 ),
49 Padding(
50 padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
51 child: Row(
52 mainAxisAlignment: MainAxisAlignment.spaceBetween,
53 children: [
54 Expanded(
55 child: Text(
56 "End Date: ${goal.endDate != null ? formatDate(goal.endDate!) : 'N/A'}",
57 textAlign: TextAlign.left,
58 ),
59 ),
60 ],
61 ),
62 ),
63 Padding(
64 padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
65 child: Row(
66 mainAxisAlignment: MainAxisAlignment.spaceBetween,
67 children: [
68 Expanded(
69 child: Text(
70 "Description: ${goal.description}",
71 overflow: TextOverflow.ellipsis,
72 ),
73 ),
74 ],
75 ),
76 ),
77 ButtonBar(
78 children: [
79 TextButton(
80 child: const Text('Go Details'),
81 onPressed: () {
82 Navigator.pushNamed(context, '/edit', arguments: goal);
83 },
84 ),
85 ],
86 ),
87 const SizedBox(height: 40)
88 ],
89 ),
90 );
91 }
92}
goal_start.dart
:Inside the goal_start.dart
file, build the "Start Goal” page.
1class StartGoal extends StatelessWidget {
2 StartGoal({super.key});
3
4 final List<GoalList> targetList = [
5 GoalList(
6 title: 'Plan your vacation',
7 icon: Icons.flight_takeoff,
8 subtitle: 'Plan your next getaway',
9 ),
10 GoalList(
11 title: 'Save Money',
12 icon: Icons.attach_money,
13 subtitle: 'Start saving money',
14 ),
15 GoalList(
16 title: 'Quit Smoking',
17 icon: Icons.smoke_free,
18 subtitle: 'Track smoke-free days',
19 ),
20 GoalList(
21 title: 'Exercise',
22 icon: Icons.directions_run,
23 subtitle: 'Keep up with your workouts',
24 ),
25 GoalList(
26 title: 'Learn a new language',
27 icon: Icons.book,
28 subtitle: 'Stay on top of your studies',
29 ),
30 ];
31
32
33 Widget build(BuildContext context) {
34 return Scaffold(
35 appBar: AppBar(title: const Text('Add a new target')),
36 body: ListView.builder(
37 itemCount: targetList.length,
38 itemBuilder: (BuildContext context, int index) {
39 return Card(
40 child: ListTile(
41 leading: Icon(
42 targetList[index].icon,
43 size: 36,
44 color: Colors.deepPurple,
45 ),
46 title: Text(targetList[index].title),
47 subtitle: Text(targetList[index].subtitle),
48 trailing: const Icon(
49 Icons.arrow_forward_ios,
50 color: Colors.deepPurple,
51 ),
52 onTap: () {
53 Navigator.pushNamed(context, '/add');
54 },
55 ),
56 );
57 },
58 ),
59 );
60 }
61}
Inside the goal_add.dart
, build the “Add Goal” page.
PageView
if you want a step-by-step guide to input the information, but a single form would be simpler and is usually sufficient.1class GoalFormPage extends ConsumerStatefulWidget {
2 const GoalFormPage({Key? key}) : super(key: key);
3
4
5 GoalFormPageState createState() => GoalFormPageState();
6}
7
8class GoalFormPageState extends ConsumerState<GoalFormPage> {
9 final _formKey = GlobalKey<FormState>();
10 final TextEditingController _nameController = TextEditingController();
11 final TextEditingController _descriptionController = TextEditingController();
12
13 DateTime? _startDate;
14 DateTime? _endDate;
15
16
17 Widget build(BuildContext context) {
18 return Scaffold(
19 appBar: AppBar(title: const Text('Add Target')),
20 body: Form(
21 key: _formKey,
22 child: SingleChildScrollView(
23 padding: const EdgeInsets.all(16.0),
24 child: Column(children: [
25 TextFormField(
26 controller: _nameController,
27 decoration: const InputDecoration(labelText: 'Goal Name'),
28 validator: (value) {
29 if (value == null || value.isEmpty) {
30 return 'Please enter a goal name';
31 }
32 return null;
33 },
34 ),
35 TextFormField(
36 controller: _descriptionController,
37 decoration: const InputDecoration(labelText: 'Description'),
38 validator: (value) {
39 if (value == null || value.isEmpty) {
40 return 'Please enter a description';
41 }
42 return null;
43 },
44 ),
45 ListTile(
46 title: Text(
47 'Start Date: ${_startDate != null ? DateFormat('yyyy-MM-dd').format(_startDate!) : 'Select'}'),
48 trailing: const Icon(Icons.calendar_today),
49 onTap: () async {
50 final picked = await showDatePicker(
51 context: context,
52 initialDate: DateTime.now(),
53 firstDate: DateTime.now(),
54 lastDate: DateTime(2050),
55 );
56 if (picked != null && picked != _startDate) {
57 setState(() {
58 _startDate = picked;
59 });
60 }
61 },
62 ),
63 ListTile(
64 title: Text(
65 'End Date: ${_endDate != null ? DateFormat('yyyy-MM-dd').format(_endDate!) : 'Select'}'),
66 trailing: const Icon(Icons.calendar_today),
67 onTap: () async {
68 final picked = await showDatePicker(
69 context: context,
70 initialDate: DateTime.now(),
71 firstDate: DateTime(2000),
72 lastDate: DateTime(2100),
73 );
74 if (picked != null && picked != _endDate) {
75 setState(() {
76 _endDate = picked;
77 });
78 }
79 },
80 ),
81 const SizedBox(
82 height: 10,
83 ),
84 ElevatedButton(
85 onPressed: _saveGoal,
86 child: const Text("Save your goal"),
87 )
88 ]))));
89 }
90
91 void _saveGoal() {
92 if (_formKey.currentState!.validate()) {
93 final newGoal = Goal(
94 id: DateTime.now().millisecondsSinceEpoch.toString(),
95 name: _nameController.text,
96 description: _descriptionController.text,
97 startDate: _startDate ?? DateTime.now(),
98 endDate: _endDate,
99 category: GoalCategory.vacation,
100 status: GoalStatus.active,
101 );
102
103 ref.read(goalProvider.notifier).addGoal(newGoal);
104
105 Navigator.pop(context);
106 }
107 }
108}
goal_add.dart
but for editing existing goals.goal
object to be edited to this page.1class GoalEditPage extends ConsumerStatefulWidget {
2 final Goal goal;
3
4 const GoalEditPage({Key? key, required this.goal}) : super(key: key);
5
6
7 ConsumerState<GoalEditPage> createState() => _GoalEditFormPageState();
8}
9
10class _GoalEditFormPageState extends ConsumerState<GoalEditPage> {
11 final _formKey = GlobalKey<FormState>();
12 final TextEditingController _nameController = TextEditingController();
13 final TextEditingController _descriptionController = TextEditingController();
14
15 DateTime? _startDate;
16 DateTime? _endDate;
17
18 void initState() {
19 super.initState();
20 _nameController.text = widget.goal.name;
21 _descriptionController.text = widget.goal.description;
22 _startDate = widget.goal.startDate;
23 _endDate = widget.goal.endDate;
24 }
25
26
27 Widget build(BuildContext context) {
28 return Scaffold(
29 appBar: AppBar(title: const Text('Edit Target')),
30 body: Form(
31 key: _formKey,
32 child: SingleChildScrollView(
33 padding: const EdgeInsets.all(16.0),
34 child: Column(children: [
35 TextFormField(
36 controller: _nameController,
37 decoration: const InputDecoration(labelText: 'Goal Name'),
38 validator: (value) {
39 if (value == null || value.isEmpty) {
40 return 'Please enter a goal name';
41 }
42 return null;
43 },
44 ),
45 TextFormField(
46 controller: _descriptionController,
47 decoration: const InputDecoration(labelText: 'Description'),
48 validator: (value) {
49 if (value == null || value.isEmpty) {
50 return 'Please enter a description';
51 }
52 return null;
53 },
54 ),
55 ListTile(
56 title: Text(
57 'Start Date: ${_startDate != null ? DateFormat('yyyy-MM-dd').format(_startDate!) : 'Select'}'),
58 trailing: const Icon(Icons.calendar_today),
59 onTap: () async {
60 final picked = await showDatePicker(
61 context: context,
62 initialDate: DateTime.now(),
63 firstDate: DateTime(2000),
64 lastDate: DateTime(2100),
65 );
66 if (picked != null && picked != _startDate) {
67 setState(() {
68 _startDate = picked;
69 });
70 }
71 },
72 ),
73 ListTile(
74 title: Text(
75 'End Date: ${_endDate != null ? DateFormat('yyyy-MM-dd').format(_endDate!) : 'Select'}'),
76 trailing: const Icon(Icons.calendar_today),
77 onTap: () async {
78 final picked = await showDatePicker(
79 context: context,
80 initialDate: DateTime.now(),
81 firstDate: DateTime(2000),
82 lastDate: DateTime(2100),
83 );
84 if (picked != null && picked != _endDate) {
85 setState(() {
86 _endDate = picked;
87 });
88 }
89 },
90 ),
91 const SizedBox(
92 height: 10,
93 ),
94 ElevatedButton(
95 onPressed: () {
96 if (_formKey.currentState!.validate()) {
97 Goal updatedGoal = Goal(
98 id: widget.goal.id,
99 name: _nameController.text,
100 description: _descriptionController.text,
101 startDate: _startDate!,
102 endDate: _endDate,
103 category: widget.goal.category,
104 status: widget.goal.status,
105 );
106
107 ref
108 .read(goalProvider.notifier)
109 .updateGoal(widget.goal.id, updatedGoal);
110
111 Navigator.pop(context);
112 }
113 },
114 child: const Text("Edit your goal"),
115 ),
116 const SizedBox(
117 height: 20,
118 ),
119 IconButton(
120 color: Theme.of(context).hintColor,
121 icon: Icon(
122 Icons.delete,
123 color: Theme.of(context).primaryColor,
124 ),
125 onPressed: () {
126 if (_formKey.currentState!.validate()) {
127 ref
128 .read(goalProvider.notifier)
129 .deleteGoal(widget.goal.id);
130
131 Navigator.pop(context);
132 }
133 },
134 )
135 ]))));
136 }
137}
If you haven't already, start by installing Strapi CMS. You can choose to use Strapi in a project-specific manner or globally. For a new project, running the command below will set up a new Strapi project and start it with a SQLite database for quick development.
npx create-strapi-app my-project --quickstart
It's generally a good idea to keep your backend and frontend projects in separate directories to maintain a clear separation of concerns. This separation helps manage dependencies, version control, and deployment processes more efficiently for each part of your application.
In this setup, both your Flutter project (personal_goals_app
) and your Strapi project (strapi_goals_app
) are located under the same parent directory (strapi_flutter
), but they are kept in separate folders.
strapi_flutter/
│
├── personal_goals_app/ # Your Flutter project
│ ├── lib/
│ ├── android/
│ ├── ios/
│ └── ...
│
└── strapi_goals_app/ # Your Strapi project
├── api/
├── config/
├── extensions/
└── ...
Before you begin interacting with data in your Flutter app, you must define the appropriate content types in Strapi that mirror the structure of your app's goals.
Log in to Strapi to access your Strapi admin panel.
Head to the "Content-Types Builder" section.
Create a new content type named Goal
.
Add fields corresponding to your Flutter app's goal model, such as:
Status (Enumeration with values like active, completed, pending, drafted).
In the Settings > Users & Permissions plugin > Roles section, configure the public role (or your preferred role) to have permissions to create, read, update, and delete entries for the Goal content types. This step is crucial for enabling interaction between your Flutter app and Strapi.
npm run strapi install graphql
Strapi will auto-generate the GraphQL schema based on your content types, accessible at /graphql
endpoint on your Strapi server.
See more here: https://pub.dev/packages/graphql_flutter
The final shape of my yaml file:
1dependencies:
2 flutter:
3 sdk: flutter
4 cupertino_icons: ^1.0.2
5 flutter_riverpod: ^2.5.1
6 build_runner: ^2.4.9
7 freezed: ^2.4.7
8 freezed_annotation: ^2.4.1
9 intl: ^0.18.0
10 graphql_flutter: ^5.1.0 —>newly added
Utilize the built-in GraphQL Playground at http://localhost:1337/graphql to explore schemas, test queries, and mutations. Define all necessary queries and mutations for your Flutter app, test them, and observe changes in Strapi.
Replace mock data in your GoalNotifier
with real data fetched from Strapi.
goal_graphql_provider.dart
: Create a provider to handle GraphQL client calls.1import 'package:flutter/material.dart';
2import 'package:flutter_riverpod/flutter_riverpod.dart';
3import 'package:graphql_flutter/graphql_flutter.dart';
4import 'package:personal_goals_app/graphql_client.dart';
5
6final graphqlClientProvider = Provider<GraphQLClient>((ref) {
7 final ValueNotifier<GraphQLClient> client = graphqlClient;
8 return client.value;
9});
graphql_client.dart
: Define the GraphQL client with the Strapi GraphQL URL.1import 'package:flutter/material.dart';
2import 'package:graphql_flutter/graphql_flutter.dart';
3
4ValueNotifier<GraphQLClient> initializeClient(String graphqlEndpoint) {
5 final HttpLink httpLink = HttpLink(graphqlEndpoint);
6 return ValueNotifier(
7 GraphQLClient(
8 link: httpLink,
9 cache: GraphQLCache(store: InMemoryStore()),
10 ),
11 );
12}
13
14const String strapiGraphQLURL = 'http://localhost:1337/graphql';
15final graphqlClient = initializeClient(strapiGraphQLURL);
To enable communication between your Flutter app and the Strapi backend, you'll need to define Strapi GraphQL mutations and queries that correspond to the actions you want to perform on the Goal content type.
mutations.dart
)
In this file, you'll define Strapi GraphQL mutations for creating, updating, and deleting goals.1// Create a new goal
2const String createGoalMutation = """
3mutation CreateGoal(\$name: String!, \$description: String!, \$startDate: Date!, \$endDate: Date, \$category: ENUM_GOAL_CATEGORY!, \$status: ENUM_GOAL_STATUS!) {
4 createGoal(data: {
5 name: \$name,
6 description: \$description,
7 startDate: \$startDate,
8 endDate: \$endDate,
9 category: \$category,
10 status: \$status
11 }) {
12 data {
13 id
14 attributes {
15 name
16 description
17 startDate
18 endDate
19 category
20 status
21 }
22 }
23 }
24}
25""";
26
27// Update an existing goal
28const String updateGoalMutation = """
29mutation UpdateGoal(\$id: ID!, \$name: String, \$description: String, \$startDate: Date, \$endDate: Date, \$category: ENUM_GOAL_CATEGORY, \$status: ENUM_GOAL_STATUS) {
30 updateGoal(id: \$id, data: {
31 name: \$name,
32 description: \$description,
33 startDate: \$startDate,
34 endDate: \$endDate,
35 category: \$category,
36 status: \$status
37 }) {
38 data {
39 id
40 attributes {
41 name
42 description
43 startDate
44 endDate
45 category
46 status
47 }
48 }
49 }
50}
51""";
52
53// Delete a goal
54const String deleteGoalMutation = """
55mutation DeleteGoal(\$id: ID!) {
56 deleteGoal(id: \$id) {
57 data {
58 id
59 }
60 }
61}
62""";
queries.dart
)
In this file, you'll define a GraphQL query for fetching all goals from the Strapi database.1const String getGoalsQuery = """
2query GetGoals {
3 goals {
4 data {
5 id
6 attributes {
7 name
8 description
9 startDate
10 endDate
11 category
12 status
13 }
14 }
15 }
16}
17""";
To integrate these mutations and queries into your Flutter app, you'll need to update the Riverpod provider (goalProvider
) to use the real queries and mutations defined above. This provider is responsible for managing the state of goals in your app and facilitating communication with the Strapi backend through GraphQL mutations and queries.
In summary, by defining GraphQL mutations and queries and updating your Riverpod provider to use them, you'll enable your Flutter app to interact seamlessly with the Strapi backend, allowing users to perform actions such as creating, updating, and deleting goals.
1 import 'package:flutter_riverpod/flutter_riverpod.dart';
2import 'package:graphql_flutter/graphql_flutter.dart';
3import 'package:intl/intl.dart';
4import 'package:personal_goals_app/src/goals/models/goal_model.dart';
5import 'package:personal_goals_app/src/graphql/mutations.dart';
6import 'package:personal_goals_app/src/graphql/queries.dart';
7import 'package:personal_goals_app/src/provider/goal_graphql_provider.dart';
8import 'package:personal_goals_app/src/provider/goal_state.dart';
9
10class GoalNotifier extends StateNotifier<GoalState> {
11 final GraphQLClient client;
12
13 GoalNotifier(this.client) : super(const GoalState(goals: []));
14
15 //Get all goals
16 Future<void> getGoals() async {
17 final QueryOptions options = QueryOptions(
18 document: gql(getGoalsQuery),
19 );
20
21 final QueryResult result = await client.query(options);
22
23 if (result.hasException) {
24 print("Exception fetching goals: ${result.exception.toString()}");
25 return;
26 }
27
28 final List<dynamic> fetchedGoals = result.data?['goals']['data'] ?? [];
29 final List<Goal> goalsList =
30 fetchedGoals.map((goalData) => Goal.fromJson(goalData)).toList();
31
32 state = state.copyWith(goals: goalsList);
33 }
34
35 // Add a new goal
36 Future<void> addGoal(Goal goal) async {
37 final MutationOptions options = MutationOptions(
38 document: gql(createGoalMutation),
39 variables: {
40 'name': goal.name,
41 'description': goal.description,
42 'startDate': DateFormat('yyyy-MM-dd').format(goal.startDate),
43 'endDate': goal.endDate != null
44 ? DateFormat('yyyy-MM-dd').format(goal.endDate!)
45 : null,
46 'category': goal.category.toString().split('.').last,
47 'status': goal.status.toString().split('.').last,
48 },
49 );
50
51 final QueryResult result = await client.mutate(options);
52
53 if (result.hasException) {
54 print("Exception adding goal: ${result.exception.toString()}");
55 return;
56 }
57
58 final newGoalData = result.data?['createGoal']['data'];
59 if (newGoalData != null) {
60 final newGoal = Goal.fromJson(newGoalData);
61 state = state.copyWith(goals: [...state.goals, newGoal]);
62 }
63 }
64
65 // Update an existing goal
66 Future<void> updateGoal(String id, Goal updatedGoal) async {
67 final MutationOptions options = MutationOptions(
68 document: gql(updateGoalMutation),
69 variables: {
70 'id': id,
71 'name': updatedGoal.name,
72 'description': updatedGoal.description,
73 'startDate': DateFormat('yyyy-MM-dd').format(updatedGoal.startDate),
74 'endDate': updatedGoal.endDate != null
75 ? DateFormat('yyyy-MM-dd').format(updatedGoal.endDate!)
76 : null,
77 'category': updatedGoal.category.toString().split('.').last,
78 'status': updatedGoal.status.toString().split('.').last,
79 },
80 );
81
82 final QueryResult result = await client.mutate(options);
83
84 if (result.hasException) {
85 print("Exception updating goal: ${result.exception.toString()}");
86 return;
87 }
88
89 await getGoals();
90 }
91
92// Delete a goal
93 Future<void> deleteGoal(String id) async {
94 final MutationOptions options = MutationOptions(
95 document: gql(deleteGoalMutation),
96 variables: {'id': id},
97 );
98
99 final QueryResult result = await client.mutate(options);
100
101 if (result.hasException) {
102 print("Exception deleting goal: ${result.exception.toString()}");
103 return;
104 }
105
106 state = state.copyWith(
107 goals: state.goals.where((goal) => goal.id != id).toList());
108 }
109}
110
111final goalProvider = StateNotifierProvider<GoalNotifier, GoalState>((ref) {
112 final client = ref.read(graphqlClientProvider);
113 return GoalNotifier(client);
114});
In the process of integrating GraphQL queries and mutations to interact with a Strapi backend, several enhancements have been made to the Goal
model. These enhancements aim to optimize data handling, ensure compatibility with GraphQL operations, and align with Strapi's data structure. Let's delve into the specific changes made to accommodate these requirements:
fromJson
has been added. This method takes a Map representing JSON data and constructs a Goal
object from it. This enhancement enables seamless conversion of JSON data retrieved from GraphQL queries into Goal
objects within the Flutter application.GoalStatus
and GoalCategory
enums play a crucial role in representing the status and category of goals. To enhance the model's versatility and compatibility with GraphQL and Strapi, methods _stringToGoalCategory
and _stringToGoalStatus
have been introduced. These methods convert string representations of enums retrieved from JSON data into their corresponding enum values. By incorporating these methods into the JSON parsing process, the model ensures consistent handling of enumerated types across different data sources and operations.1enum GoalStatus { active, completed, pending }
2
3enum GoalCategory { vacation, money, exercise, smoke, language }
4
5class Goal {
6 final String id;
7 final String name;
8 final String description;
9 final DateTime startDate;
10 final DateTime?
11 endDate; // End date is optional because some goals might not have a specific end date
12 final GoalCategory category;
13 GoalStatus status;
14 double?
15 targetValue; // Numeric value representing the goal target (e.g., amount to save)
16 double?
17 currentValue; // Current progress towards the goal (e.g., current savings)
18
19 Goal({
20 required this.id,
21 required this.name,
22 required this.description,
23 required this.startDate,
24 this.endDate,
25 required this.category,
26 this.status = GoalStatus.pending,
27 this.targetValue,
28 this.currentValue,
29 });
30
31 factory Goal.fromJson(Map<String, dynamic> json) {
32 var attributes = json['attributes'];
33 return Goal(
34 id: json['id'].toString(), // Ensuring `id` is treated as a String.
35 name: attributes['name'] ??
36 '', // Providing a default empty string if `name` is null.
37 description: attributes['description'] ?? '',
38 startDate: DateTime.parse(attributes['startDate']),
39 endDate: attributes['endDate'] != null
40 ? DateTime.parse(attributes['endDate'])
41 : null,
42 category: _stringToGoalCategory(attributes['category'] ?? 'vacation'),
43 status: _stringToGoalStatus(attributes['status'] ?? 'pending'),
44 targetValue: attributes['targetValue'],
45 currentValue: attributes['currentValue'],
46 );
47 }
48 // Calculate the status of the goal based on dates
49 static GoalStatus calculateStatus(DateTime startDate, DateTime endDate) {
50 final currentDate = DateTime.now();
51 if (currentDate.isAfter(endDate)) {
52 return GoalStatus.completed;
53 } else if (currentDate.isAfter(startDate)) {
54 return GoalStatus.active;
55 } else {
56 return GoalStatus.pending;
57 }
58 }
59
60 static GoalCategory _stringToGoalCategory(String category) {
61 return GoalCategory.values.firstWhere(
62 (e) => e.toString().split('.').last == category,
63 orElse: () => GoalCategory.vacation,
64 );
65 }
66
67 static GoalStatus _stringToGoalStatus(String status) {
68 return GoalStatus.values.firstWhere(
69 (e) => e.toString().split('.').last == status,
70 orElse: () => GoalStatus.pending,
71 );
72 }
73}
HomePage
To display the goals fetched from Strapi in your Flutter app, you'll need to call the published data from Strapi in your home page (home.dart
).
1import 'package:flutter/material.dart';
2import 'package:flutter_riverpod/flutter_riverpod.dart';
3import 'package:personal_goals_app/src/goals/components/goal_card.dart';
4import 'package:personal_goals_app/src/provider/goal_provider.dart';
5
6class HomePage extends ConsumerStatefulWidget {
7 const HomePage({Key? key}) : super(key: key);
8
9
10 HomePageState createState() => HomePageState();
11}
12
13class HomePageState extends ConsumerState<HomePage> {
14
15 void initState() {
16 super.initState();
17 Future.microtask(() => ref.read(goalProvider.notifier).getGoals());
18 }
19
20
21 Widget build(BuildContext context) {
22 final goals = ref.watch(goalProvider).goals;
23
24 return Scaffold(
25 appBar: AppBar(title: const Text('Targets')),
26 body: ListView.builder(
27 itemCount: goals.length,
28 itemBuilder: (context, index) {
29 final goal = goals[index];
30 return GoalCard(goal: goal);
31 },
32 ),
33 floatingActionButton: FloatingActionButton.extended(
34 onPressed: () async {
35 final refreshNeeded = await Navigator.pushNamed(context, '/start');
36 if (refreshNeeded == true) {
37 ref.read(goalProvider.notifier).getGoals();
38 }
39 },
40 label: const Text('Add New Target'),
41 icon: const Icon(Icons.add),
42 ));
43 }
44}
In this file, you'll use the ConsumerWidget
provided by Riverpod to fetch the goals from Strapi and display them in a list view.
Fetching Goals: Inside the build method, you'll call the getGoals
method from the goalProvider
notifier to fetch the goals from Strapi. The ref.watch(goalProvider)
statement will ensure that the widget rebuilds whenever the state of the goalProvider
changes. By following this approach, you'll have a clean and efficient way to fetch and display the goals from Strapi in your Flutter app's home page.
NOTE: Lastly, ensure that the draft mode is disabled in Strapi to see the published data in your app.
This integration enables seamless communication between your Flutter app and Strapi CMS, allowing users to view and interact with the goals stored in the backend.
By the end of this tutorial, you should have a working personal tracking application that allows a user add, start and edit a goal or target.
Strapi API provides a powerful and customizable API for managing content and data. With Strapi, we can define custom content types, set permissions, and expose APIs tailored to our application's needs. Personally, It is very easy to use and quick to learn. Benefits of Using Riverpod, Flutter, GraphQL, and Strapi Together:
Computer Engineering graduate, proficient in building mobile applications with Flutter and web applications with Vue.js. Beyond coding, I enrich my time playing an instrument, learning new languages, exploring diverse cultures, and developing my own mobile app. As a tech writer, I'm dedicated to sharing knowledge and insights. I am always eager to blend technical skills with creative pursuits.