DEV Community

Cover image for Build a Personal Target Tracking Application with Flutter Riverpod and Strapi GraphQL
Strapi for Strapi

Posted on

Build a Personal Target Tracking Application with Flutter Riverpod and Strapi GraphQL

Introduction

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.

Prerequisites

Before starting the tutorial on developing a personal target tracking application with Flutter, Riverpod, Strapi, and GraphQL, ensure you meet the following requirements:

  • Development Environment: Install Flutter and the Dart SDK from flutter.dev. Ensure you have Node.js installed for running Strapi.
  • Knowledge Base: Basic familiarity with Flutter and Dart for mobile development, understanding of GraphQL for data handling, and experience with Node.js-based CMS like Strapi.
  • Tools: A code editor like Visual Studio Code or Android Studio, equipped with support for Flutter and JavaScript.

Why Choose Riverpod for State Management?

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:

  • Scoped Access to State: Riverpod allows for the scoped access of state, ensuring widgets have access to the state they need and nothing more. This encapsulation enhances performance and reduces the likelihood of unintended side effects.
  • Flexible State Modification: Riverpod simplifies state modification with its support for immutable state objects. By embracing the principles of immutability, it ensures that state changes are predictable and manageable.
  • Incorporating Riverpod with Freezed for Immutable State Management: To leverage Riverpod alongside Freezed in your Flutter project for even more robust state management, you'll be enhancing components like 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.
  • Combining Strapi with GraphQL for your Flutter application: When managed with Riverpod, creates a robust, efficient, and flexible development ecosystem.
  • Flexibility and Customizability: Strapi is a headless CMS that provides the flexibility to define your data structures (models), content types, and relations tailored to your application needs. This adaptability is crucial for Flutter apps requiring a custom data set.

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.

Content Flow

Requirements

  • UI Development: Flutter widgets can easily create your application's user interface. This shapes the look and feel of your application.
  • State Management: Developing the core part of your application with a state management system enhances the scalability and maintainability of your app. Riverpod offers a strong and flexible solution in this regard.
  • Backend Integration: The ease provided by the Strapi API makes backend integration less cumbersome at this stage of the project. Working with Strapi offers a significant advantage in terms of data management and API calls.

Integration Steps:

  • Setting Up Components: Create your application's user interface (UI) components.
  • Creating Providers: Use Riverpod to create providers for state management. These providers manage the data flow between different parts of your application.
  • Adding Routers: Set up routers to manage transitions between pages. This ensures a smooth navigation experience within the app.
  • Testing with Mock Data: In the initial stage, test your providers and UI components with mock data. This provides quick feedback on your application's data structures and workflows.

Transition from Mock Data to Strapi and GraphQL:

  • Strapi Setup and Configuration: Set up your Strapi project and create the necessary API endpoints. These endpoints define the data types and structures your application needs.
  • Defining GraphQL Schemas: From the Strapi administration panel, define your GraphQL schemas. These schemas determine the structure of the data your application will query from Strapi.
  • Updating Your Providers: Update the providers in your application to make queries to real Strapi endpoints instead of mock data. Configure your Riverpod providers to execute your GraphQL queries and connect the data to your application.
  • Queries and Mutations: Write your GraphQL queries and mutations. These queries enable your application to fetch or send data to Strapi.

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
Enter fullscreen mode Exit fullscreen mode

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.

Set Up Flutter Project

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
Enter fullscreen mode Exit fullscreen mode

001-create-project.png

Navigate to the created project directory by running the command below:

cd personal_goals_app
Enter fullscreen mode Exit fullscreen mode

Start your application with the command below:

flutter run
Enter fullscreen mode Exit fullscreen mode

This confirms that your first Flutter application is running successfully.

VSCode Command Palette:

002-vscode-command-pallete.png

VSCode Terminal:

003-vscode-terminal.png

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.

004-folder-structure.png

The src/goals directory contains your Goal model and general files. The main.dart file includes your application's navigation and basic settings.

Set Up Providers with Riverpod

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:

dependencies:
  flutter:
    sdk: flutter

  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^1.0.2
  flutter_riverpod: ^2.5.1
  intl: ^0.18.0
Enter fullscreen mode Exit fullscreen mode

Define Goal Model

In the goal_model.dart file, define Goal model. Use the Goal class and GoalStatus enum.

enum GoalStatus { active, completed, pending }

enum GoalCategory { vacation, money, exercise, smoke, language }

class Goal {
  final String id;
  final String name;
  final String description;
  final DateTime startDate;
  final DateTime?
      endDate; // End date is optional because some goals might not have a specific end date
  final GoalCategory category;
  GoalStatus status;
  double?
      targetValue; // Numeric value representing the goal target (e.g., amount to save)
  double?
      currentValue; // Current progress towards the goal (e.g., current savings)

  Goal({
    required this.id,
    required this.name,
    required this.description,
    required this.startDate,
    this.endDate,
    required this.category,
    this.status = GoalStatus.pending,
    this.targetValue,
    this.currentValue,
  });

  // Calculate the status of the goal based on dates
  static GoalStatus calculateStatus(DateTime startDate, DateTime endDate) {
    final currentDate = DateTime.now();
    if (currentDate.isAfter(endDate)) {
      return GoalStatus.completed;
    } else if (currentDate.isAfter(startDate)) {
      return GoalStatus.active;
    } else {
      return GoalStatus.pending;
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

Define Your State with Freezed

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.

import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:personal_goals_app/src/goals/models/goal_model.dart';
part 'goal_state.freezed.dart';

@freezed
class GoalState with _$GoalState {
  const factory GoalState({
    @Default([]) List<Goal> goals,
  }) = _GoalState;
}

Enter fullscreen mode Exit fullscreen mode

Create a State Notifier:

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.

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:personal_goals_app/src/goals/models/goal_model.dart';
import 'package:personal_goals_app/src/provider/goal_state.dart';

class GoalNotifier extends StateNotifier<GoalState> {
  GoalNotifier()
      : super(GoalState(goals: [
          Goal(
            id: '1',
            name: 'Vacation in Milan',
            description: 'Enjoy the beauty of Milan',
            startDate: DateTime(2024, 04, 29),
            endDate: DateTime(2024, 11, 1),
            category: GoalCategory.vacation,
            status: GoalStatus.active,
          ),
          Goal(
            id: '2',
            name: 'Quit Smoking',
            description:
                'Reduce cigarette intake gradually and increase smoke-free days',
            startDate: DateTime.now(),
            endDate: DateTime.now().add(const Duration(days: 90)),
            category: GoalCategory.smoke,
          ),
        ]));

  // Add a new goal
  void addGoal(Goal goal) {
    state = state.copyWith(goals: [...state.goals, goal]);
  }

  // Update an existing goal
  void updateGoal(String id, Goal updatedGoal) {
    state = state.copyWith(
      goals: state.goals
          .map((goal) => goal.id == id ? updatedGoal : goal)
          .toList(),
    );
  }

  // Delete a goal
  void deleteGoal(String id) {
    state = state.copyWith(
      goals: state.goals.where((goal) => goal.id != id).toList(),
    );
  }
}

final goalProvider = StateNotifierProvider<GoalNotifier, GoalState>((ref) {
  return GoalNotifier();
});

Enter fullscreen mode Exit fullscreen mode

Wrap Your Application with 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.

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

Enter fullscreen mode Exit fullscreen mode

Setting Up Your Router and Components

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.

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Strapi Api Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const HomePage(),
      initialRoute: '/',
      routes: {
        '/start': (context) => StartGoal(),
        '/add': (context) => const GoalFormPage(),
      },
      onGenerateRoute: (settings) {
        if (settings.name == '/edit') {
          final goal = settings.arguments as Goal;
          return MaterialPageRoute(
            builder: (context) {
              return GoalEditPage(goal: goal);
            },
          );
        }
        return null;
      },
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

Create UI Components:

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.

005-create-ui-components.png

Implement Home Page:

Implement the Home page in home.dart.

  • Display a list of goal cards.
  • Use a FloatingActionButton to navigate to the “Add Goal” page.
  • Call the provider here to read data.
class HomePage extends ConsumerWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final goals = ref.watch(goalProvider).goals;

    return Scaffold(
      appBar: AppBar(
        title: const Text('Targets'),
      ),
      body: ListView.builder(
        itemCount: goals.length,
        itemBuilder: (context, index) {
          final goal = goals[index];
          return GoalCard(goal: goal);
        },
      ),
      floatingActionButton: FloatingActionButton.extended(
        onPressed: () {
          Navigator.pushNamed(context, '/start');
        },
        label: const Text('Add New Target'),
        icon: const Icon(Icons.add),
      ),
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

Create Goal Cards:

Create Goal cards in goal_card.dart.

  • Design a card widget that displays goal information.
  • Add buttons or gestures to each card to trigger edit or delete actions.
class GoalCard extends StatelessWidget {
  final Goal goal;

  const GoalCard({Key? key, required this.goal}) : super(key: key);

  String formatDate(DateTime date) {
    return '${date.month}/${date.year}';
  }

  Color getStatusColor(GoalStatus status) {
    switch (status) {
      case GoalStatus.active:
        return Colors.deepPurple;
      case GoalStatus.pending:
        return Colors.blue;
      case GoalStatus.completed:
        return Colors.green;
      default:
        return Colors.grey;
    }
  }

  @override
  Widget build(BuildContext context) {
    goal.status = Goal.calculateStatus(goal.startDate, goal.endDate!);

    return Card(
      margin: const EdgeInsets.all(24),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: <Widget>[
          Container(
            width: 120,
            color: getStatusColor(goal.status),
            padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16),
            alignment: Alignment.center,
            child: Text(
              goal.status.toString().split('.').last.toUpperCase(),
              style: const TextStyle(color: Colors.white),
            ),
          ),
          ListTile(
            leading: const Icon(Icons.track_changes),
            title: Text(goal.name),
            subtitle: Text(
              'Target duration: ${goal.endDate?.difference(goal.startDate).inDays ?? 'N/A'} days',
            ),
          ),
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Expanded(
                  child: Text(
                    "End Date: ${goal.endDate != null ? formatDate(goal.endDate!) : 'N/A'}",
                    textAlign: TextAlign.left,
                  ),
                ),
              ],
            ),
          ),
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Expanded(
                  child: Text(
                    "Description: ${goal.description}",
                    overflow: TextOverflow.ellipsis,
                  ),
                ),
              ],
            ),
          ),
          ButtonBar(
            children: [
              TextButton(
                child: const Text('Go Details'),
                onPressed: () {
                  Navigator.pushNamed(context, '/edit', arguments: goal);
                },
              ),
            ],
          ),
          const SizedBox(height: 40)
        ],
      ),
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

006-create-goal-card.png

Build the Start Goal Page in goal_start.dart:

Inside the goal_start.dart file, build the "Start Goal” page.

class StartGoal extends StatelessWidget {
  StartGoal({super.key});

  final List<GoalList> targetList = [
    GoalList(
      title: 'Plan your vacation',
      icon: Icons.flight_takeoff,
      subtitle: 'Plan your next getaway',
    ),
    GoalList(
      title: 'Save Money',
      icon: Icons.attach_money,
      subtitle: 'Start saving money',
    ),
    GoalList(
      title: 'Quit Smoking',
      icon: Icons.smoke_free,
      subtitle: 'Track smoke-free days',
    ),
    GoalList(
      title: 'Exercise',
      icon: Icons.directions_run,
      subtitle: 'Keep up with your workouts',
    ),
    GoalList(
      title: 'Learn a new language',
      icon: Icons.book,
      subtitle: 'Stay on top of your studies',
    ),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Add a new target')),
      body: ListView.builder(
        itemCount: targetList.length,
        itemBuilder: (BuildContext context, int index) {
          return Card(
            child: ListTile(
              leading: Icon(
                targetList[index].icon,
                size: 36,
                color: Colors.deepPurple,
              ),
              title: Text(targetList[index].title),
              subtitle: Text(targetList[index].subtitle),
              trailing: const Icon(
                Icons.arrow_forward_ios,
                color: Colors.deepPurple,
              ),
              onTap: () {
                Navigator.pushNamed(context, '/add');
              },
            ),
          );
        },
      ),
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

007-start-goal-page.png

Build the “Add Goal” Page:

Inside the goal_add.dart, build the “Add Goal” page.

  • Create a form for adding a new goal.
  • Consider using a PageView if you want a step-by-step guide to input the information, but a single form would be simpler and is usually sufficient.
class GoalFormPage extends ConsumerStatefulWidget {
  const GoalFormPage({Key? key}) : super(key: key);

  @override
  GoalFormPageState createState() => GoalFormPageState();
}

class GoalFormPageState extends ConsumerState<GoalFormPage> {
  final _formKey = GlobalKey<FormState>();
  final TextEditingController _nameController = TextEditingController();
  final TextEditingController _descriptionController = TextEditingController();

  DateTime? _startDate;
  DateTime? _endDate;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(title: const Text('Add Target')),
        body: Form(
            key: _formKey,
            child: SingleChildScrollView(
                padding: const EdgeInsets.all(16.0),
                child: Column(children: [
                  TextFormField(
                    controller: _nameController,
                    decoration: const InputDecoration(labelText: 'Goal Name'),
                    validator: (value) {
                      if (value == null || value.isEmpty) {
                        return 'Please enter a goal name';
                      }
                      return null;
                    },
                  ),
                  TextFormField(
                    controller: _descriptionController,
                    decoration: const InputDecoration(labelText: 'Description'),
                    validator: (value) {
                      if (value == null || value.isEmpty) {
                        return 'Please enter a description';
                      }
                      return null;
                    },
                  ),
                  ListTile(
                    title: Text(
                        'Start Date: ${_startDate != null ? DateFormat('yyyy-MM-dd').format(_startDate!) : 'Select'}'),
                    trailing: const Icon(Icons.calendar_today),
                    onTap: () async {
                      final picked = await showDatePicker(
                        context: context,
                        initialDate: DateTime.now(),
                        firstDate: DateTime.now(),
                        lastDate: DateTime(2050),
                      );
                      if (picked != null && picked != _startDate) {
                        setState(() {
                          _startDate = picked;
                        });
                      }
                    },
                  ),
                  ListTile(
                    title: Text(
                        'End Date: ${_endDate != null ? DateFormat('yyyy-MM-dd').format(_endDate!) : 'Select'}'),
                    trailing: const Icon(Icons.calendar_today),
                    onTap: () async {
                      final picked = await showDatePicker(
                        context: context,
                        initialDate: DateTime.now(),
                        firstDate: DateTime(2000),
                        lastDate: DateTime(2100),
                      );
                      if (picked != null && picked != _endDate) {
                        setState(() {
                          _endDate = picked;
                        });
                      }
                    },
                  ),
                  const SizedBox(
                    height: 10,
                  ),
                  ElevatedButton(
                    onPressed: _saveGoal,
                    child: const Text("Save your goal"),
                  )
                ]))));
  }

  void _saveGoal() {
    if (_formKey.currentState!.validate()) {
      final newGoal = Goal(
        id: DateTime.now().millisecondsSinceEpoch.toString(),
        name: _nameController.text,
        description: _descriptionController.text,
        startDate: _startDate ?? DateTime.now(),
        endDate: _endDate,
        category: GoalCategory.vacation,
        status: GoalStatus.active,
      );

      ref.read(goalProvider.notifier).addGoal(newGoal);

      Navigator.pop(context);
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

008-add-goal-page.png

Craft the Edit Goal Page in goal_edit.dart:

  • This will be similar to goal_add.dart but for editing existing goals.
  • Ensure you pass the goal object to be edited to this page.
class GoalEditPage extends ConsumerStatefulWidget {
  final Goal goal;

  const GoalEditPage({Key? key, required this.goal}) : super(key: key);

  @override
  ConsumerState<GoalEditPage> createState() => _GoalEditFormPageState();
}

class _GoalEditFormPageState extends ConsumerState<GoalEditPage> {
  final _formKey = GlobalKey<FormState>();
  final TextEditingController _nameController = TextEditingController();
  final TextEditingController _descriptionController = TextEditingController();

  DateTime? _startDate;
  DateTime? _endDate;
  @override
  void initState() {
    super.initState();
    _nameController.text = widget.goal.name;
    _descriptionController.text = widget.goal.description;
    _startDate = widget.goal.startDate;
    _endDate = widget.goal.endDate;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(title: const Text('Edit Target')),
        body: Form(
            key: _formKey,
            child: SingleChildScrollView(
                padding: const EdgeInsets.all(16.0),
                child: Column(children: [
                  TextFormField(
                    controller: _nameController,
                    decoration: const InputDecoration(labelText: 'Goal Name'),
                    validator: (value) {
                      if (value == null || value.isEmpty) {
                        return 'Please enter a goal name';
                      }
                      return null;
                    },
                  ),
                  TextFormField(
                    controller: _descriptionController,
                    decoration: const InputDecoration(labelText: 'Description'),
                    validator: (value) {
                      if (value == null || value.isEmpty) {
                        return 'Please enter a description';
                      }
                      return null;
                    },
                  ),
                  ListTile(
                    title: Text(
                        'Start Date: ${_startDate != null ? DateFormat('yyyy-MM-dd').format(_startDate!) : 'Select'}'),
                    trailing: const Icon(Icons.calendar_today),
                    onTap: () async {
                      final picked = await showDatePicker(
                        context: context,
                        initialDate: DateTime.now(),
                        firstDate: DateTime(2000),
                        lastDate: DateTime(2100),
                      );
                      if (picked != null && picked != _startDate) {
                        setState(() {
                          _startDate = picked;
                        });
                      }
                    },
                  ),
                  ListTile(
                    title: Text(
                        'End Date: ${_endDate != null ? DateFormat('yyyy-MM-dd').format(_endDate!) : 'Select'}'),
                    trailing: const Icon(Icons.calendar_today),
                    onTap: () async {
                      final picked = await showDatePicker(
                        context: context,
                        initialDate: DateTime.now(),
                        firstDate: DateTime(2000),
                        lastDate: DateTime(2100),
                      );
                      if (picked != null && picked != _endDate) {
                        setState(() {
                          _endDate = picked;
                        });
                      }
                    },
                  ),
                  const SizedBox(
                    height: 10,
                  ),
                  ElevatedButton(
                    onPressed: () {
                      if (_formKey.currentState!.validate()) {
                        Goal updatedGoal = Goal(
                          id: widget.goal.id,
                          name: _nameController.text,
                          description: _descriptionController.text,
                          startDate: _startDate!,
                          endDate: _endDate,
                          category: widget.goal.category,
                          status: widget.goal.status,
                        );

                        ref
                            .read(goalProvider.notifier)
                            .updateGoal(widget.goal.id, updatedGoal);

                        Navigator.pop(context);
                      }
                    },
                    child: const Text("Edit your goal"),
                  ),
                  const SizedBox(
                    height: 20,
                  ),
                  IconButton(
                    color: Theme.of(context).hintColor,
                    icon: Icon(
                      Icons.delete,
                      color: Theme.of(context).primaryColor,
                    ),
                    onPressed: () {
                      if (_formKey.currentState!.validate()) {
                        ref
                            .read(goalProvider.notifier)
                            .deleteGoal(widget.goal.id);

                        Navigator.pop(context);
                      }
                    },
                  )
                ]))));
  }
}

Enter fullscreen mode Exit fullscreen mode

009-edit-goal-page.png

Transition from Mock Data to Strapi and GraphQL:

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
Enter fullscreen mode Exit fullscreen mode

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/
└── ...
Enter fullscreen mode Exit fullscreen mode

010-install-strapi.png

Setting up Strapi Content Types

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.

  1. Log in to Strapi to access your Strapi admin panel.
    011-log-in-to-strapi.png

  2. Head to the "Content-Types Builder" section.

  3. Create a new content type named Goal .

012-create-collection-type.png

  1. Add fields corresponding to your Flutter app's goal model, such as:
  • Name (Text)
  • Description (Text)
  • Start Date and End Date (Date)
  • Category (Enumeration)
  • Status (Enumeration with values like active, completed, pending, drafted).

013-collection-type-fields.png

  1. Save Content Type: Save the content type, and Strapi will automatically restart.

Setting Permissions

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.

014-set-permissions.png

Defining GraphQL Schemas

  • Enable GraphQL: Note that in Strapi v4, the GraphQL plugin is not installed by default. Install it by running the command below in your Strapi project directory.
npm run strapi install graphql
Enter fullscreen mode Exit fullscreen mode

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:

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.2
  flutter_riverpod: ^2.5.1
  build_runner: ^2.4.9
  freezed: ^2.4.7
  freezed_annotation: ^2.4.1
  intl: ^0.18.0
  graphql_flutter: ^5.1.0 —>newly added
Enter fullscreen mode Exit fullscreen mode
  • Exploring with GraphQL Playground

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.

015-graphql-playground.png

Update Flutter App for Strapi and GraphQL Integration

Replace mock data in your GoalNotifier with real data fetched from Strapi.

  • Creating goal_graphql_provider.dart: Create a provider to handle GraphQL client calls.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:graphql_flutter/graphql_flutter.dart';
import 'package:personal_goals_app/graphql_client.dart';

final graphqlClientProvider = Provider<GraphQLClient>((ref) {
  final ValueNotifier<GraphQLClient> client = graphqlClient;
  return client.value;
});

Enter fullscreen mode Exit fullscreen mode
  • Creating graphql_client.dart: Define the GraphQL client with the Strapi GraphQL URL.
import 'package:flutter/material.dart';
import 'package:graphql_flutter/graphql_flutter.dart';

ValueNotifier<GraphQLClient> initializeClient(String graphqlEndpoint) {
  final HttpLink httpLink = HttpLink(graphqlEndpoint);
  return ValueNotifier(
    GraphQLClient(
      link: httpLink,
      cache: GraphQLCache(store: InMemoryStore()),
    ),
  );
}

const String strapiGraphQLURL = 'http://localhost:1337/graphql';
final graphqlClient = initializeClient(strapiGraphQLURL);

Enter fullscreen mode Exit fullscreen mode

Creating GraphQL Mutations and Queries

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.

  1. Mutations (mutations.dart) In this file, you'll define Strapi GraphQL mutations for creating, updating, and deleting goals.
  • Create Goal Mutation: This mutation creates a new goal in the Strapi database. It includes parameters for the goal's name, description, start date, end date, category, and status.
  • Update Goal Mutation: This mutation updates an existing goal in the Strapi database. It includes parameters for the goal's ID and updated fields such as name, description, start date, end date, category, and status.
  • Delete Goal Mutation: This mutation deletes a goal from the Strapi database based on its ID.
// Create a new goal
const String createGoalMutation = """
mutation CreateGoal(\$name: String!, \$description: String!, \$startDate: Date!, \$endDate: Date, \$category: ENUM_GOAL_CATEGORY!, \$status: ENUM_GOAL_STATUS!) {
  createGoal(data: {
    name: \$name,
    description: \$description,
    startDate: \$startDate,
    endDate: \$endDate,
    category: \$category,
    status: \$status
  }) {
    data {
      id
      attributes {
        name
        description
        startDate
        endDate
        category
        status
      }
    }
  }
}
""";

// Update an existing goal
const String updateGoalMutation = """
mutation UpdateGoal(\$id: ID!, \$name: String, \$description: String, \$startDate: Date, \$endDate: Date, \$category: ENUM_GOAL_CATEGORY, \$status: ENUM_GOAL_STATUS) {
  updateGoal(id: \$id, data: {
    name: \$name,
    description: \$description,
    startDate: \$startDate,
    endDate: \$endDate,
    category: \$category,
    status: \$status
  }) {
    data {
      id
      attributes {
        name
        description
        startDate
        endDate
        category
        status
      }
    }
  }
}
""";

// Delete a goal
const String deleteGoalMutation = """
mutation DeleteGoal(\$id: ID!) {
  deleteGoal(id: \$id) {
    data {
      id
    }
  }
}
""";
Enter fullscreen mode Exit fullscreen mode
  1. Queries (queries.dart) In this file, you'll define a GraphQL query for fetching all goals from the Strapi database.
  • Get Goals Query: This query fetches all goals stored in the Strapi database. It retrieves the ID, name, description, start date, end date, category, and status for each goal.
const String getGoalsQuery = """
query GetGoals {
  goals {
    data {
      id
      attributes {
        name
        description
        startDate
        endDate
        category
        status
      }
    }
  }
}
""";
Enter fullscreen mode Exit fullscreen mode

Updating Riverpod Provider

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.

 import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:graphql_flutter/graphql_flutter.dart';
import 'package:intl/intl.dart';
import 'package:personal_goals_app/src/goals/models/goal_model.dart';
import 'package:personal_goals_app/src/graphql/mutations.dart';
import 'package:personal_goals_app/src/graphql/queries.dart';
import 'package:personal_goals_app/src/provider/goal_graphql_provider.dart';
import 'package:personal_goals_app/src/provider/goal_state.dart';

class GoalNotifier extends StateNotifier<GoalState> {
  final GraphQLClient client;

  GoalNotifier(this.client) : super(const GoalState(goals: []));

  //Get all goals
  Future<void> getGoals() async {
    final QueryOptions options = QueryOptions(
      document: gql(getGoalsQuery),
    );

    final QueryResult result = await client.query(options);

    if (result.hasException) {
      print("Exception fetching goals: ${result.exception.toString()}");
      return;
    }

    final List<dynamic> fetchedGoals = result.data?['goals']['data'] ?? [];
    final List<Goal> goalsList =
        fetchedGoals.map((goalData) => Goal.fromJson(goalData)).toList();

    state = state.copyWith(goals: goalsList);
  }

  // Add a new goal
  Future<void> addGoal(Goal goal) async {
    final MutationOptions options = MutationOptions(
      document: gql(createGoalMutation),
      variables: {
        'name': goal.name,
        'description': goal.description,
        'startDate': DateFormat('yyyy-MM-dd').format(goal.startDate),
        'endDate': goal.endDate != null
            ? DateFormat('yyyy-MM-dd').format(goal.endDate!)
            : null,
        'category': goal.category.toString().split('.').last,
        'status': goal.status.toString().split('.').last,
      },
    );

    final QueryResult result = await client.mutate(options);

    if (result.hasException) {
      print("Exception adding goal: ${result.exception.toString()}");
      return;
    }

    final newGoalData = result.data?['createGoal']['data'];
    if (newGoalData != null) {
      final newGoal = Goal.fromJson(newGoalData);
      state = state.copyWith(goals: [...state.goals, newGoal]);
    }
  }

  // Update an existing goal
  Future<void> updateGoal(String id, Goal updatedGoal) async {
    final MutationOptions options = MutationOptions(
      document: gql(updateGoalMutation),
      variables: {
        'id': id,
        'name': updatedGoal.name,
        'description': updatedGoal.description,
        'startDate': DateFormat('yyyy-MM-dd').format(updatedGoal.startDate),
        'endDate': updatedGoal.endDate != null
            ? DateFormat('yyyy-MM-dd').format(updatedGoal.endDate!)
            : null,
        'category': updatedGoal.category.toString().split('.').last,
        'status': updatedGoal.status.toString().split('.').last,
      },
    );

    final QueryResult result = await client.mutate(options);

    if (result.hasException) {
      print("Exception updating goal: ${result.exception.toString()}");
      return;
    }

    await getGoals();
  }

// Delete a goal
  Future<void> deleteGoal(String id) async {
    final MutationOptions options = MutationOptions(
      document: gql(deleteGoalMutation),
      variables: {'id': id},
    );

    final QueryResult result = await client.mutate(options);

    if (result.hasException) {
      print("Exception deleting goal: ${result.exception.toString()}");
      return;
    }

    state = state.copyWith(
        goals: state.goals.where((goal) => goal.id != id).toList());
  }
}

final goalProvider = StateNotifierProvider<GoalNotifier, GoalState>((ref) {
  final client = ref.read(graphqlClientProvider);
  return GoalNotifier(client);
});

Enter fullscreen mode Exit fullscreen mode

Updating Goal Model

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:

  • With the introduction of GraphQL queries, data is often retrieved in JSON format. To efficiently parse JSON data into the Goal model, a factory method 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.
  • The 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.
enum GoalStatus { active, completed, pending }

enum GoalCategory { vacation, money, exercise, smoke, language }

class Goal {
  final String id;
  final String name;
  final String description;
  final DateTime startDate;
  final DateTime?
      endDate; // End date is optional because some goals might not have a specific end date
  final GoalCategory category;
  GoalStatus status;
  double?
      targetValue; // Numeric value representing the goal target (e.g., amount to save)
  double?
      currentValue; // Current progress towards the goal (e.g., current savings)

  Goal({
    required this.id,
    required this.name,
    required this.description,
    required this.startDate,
    this.endDate,
    required this.category,
    this.status = GoalStatus.pending,
    this.targetValue,
    this.currentValue,
  });

  factory Goal.fromJson(Map<String, dynamic> json) {
    var attributes = json['attributes'];
    return Goal(
      id: json['id'].toString(), // Ensuring `id` is treated as a String.
      name: attributes['name'] ??
          '', // Providing a default empty string if `name` is null.
      description: attributes['description'] ?? '',
      startDate: DateTime.parse(attributes['startDate']),
      endDate: attributes['endDate'] != null
          ? DateTime.parse(attributes['endDate'])
          : null,
      category: _stringToGoalCategory(attributes['category'] ?? 'vacation'),
      status: _stringToGoalStatus(attributes['status'] ?? 'pending'),
      targetValue: attributes['targetValue'],
      currentValue: attributes['currentValue'],
    );
  }
  // Calculate the status of the goal based on dates
  static GoalStatus calculateStatus(DateTime startDate, DateTime endDate) {
    final currentDate = DateTime.now();
    if (currentDate.isAfter(endDate)) {
      return GoalStatus.completed;
    } else if (currentDate.isAfter(startDate)) {
      return GoalStatus.active;
    } else {
      return GoalStatus.pending;
    }
  }

  static GoalCategory _stringToGoalCategory(String category) {
    return GoalCategory.values.firstWhere(
      (e) => e.toString().split('.').last == category,
      orElse: () => GoalCategory.vacation,
    );
  }

  static GoalStatus _stringToGoalStatus(String status) {
    return GoalStatus.values.firstWhere(
      (e) => e.toString().split('.').last == status,
      orElse: () => GoalStatus.pending,
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

Fetching Data from Strapi in 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).

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:personal_goals_app/src/goals/components/goal_card.dart';
import 'package:personal_goals_app/src/provider/goal_provider.dart';

class HomePage extends ConsumerStatefulWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  HomePageState createState() => HomePageState();
}

class HomePageState extends ConsumerState<HomePage> {
  @override
  void initState() {
    super.initState();
    Future.microtask(() => ref.read(goalProvider.notifier).getGoals());
  }

  @override
  Widget build(BuildContext context) {
    final goals = ref.watch(goalProvider).goals;

    return Scaffold(
        appBar: AppBar(title: const Text('Targets')),
        body: ListView.builder(
          itemCount: goals.length,
          itemBuilder: (context, index) {
            final goal = goals[index];
            return GoalCard(goal: goal);
          },
        ),
        floatingActionButton: FloatingActionButton.extended(
          onPressed: () async {
            final refreshNeeded = await Navigator.pushNamed(context, '/start');
            if (refreshNeeded == true) {
              ref.read(goalProvider.notifier).getGoals();
            }
          },
          label: const Text('Add New Target'),
          icon: const Icon(Icons.add),
        ));
  }
}

Enter fullscreen mode Exit fullscreen mode

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.

016-edit-configuration.png

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.

Demo Time!

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.

personal-tracker-gif.gif

Conclusion

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:

  • Efficiency: Flutter Riverpod and GraphQL enable efficient state management and data fetching, reducing unnecessary network requests and enhancing app performance.
  • Flexibility: GraphQL's flexible query language and Strapi's customizable APIs empower developers to tailor data interactions precisely to their application's requirements.
  • Scalability: With Riverpod, Flutter, GraphQL, and Strapi, applications can easily scale to accommodate growing user bases and evolving feature sets.
  • Productivity: The combination of these technologies streamlines development workflows, allowing developers to focus on building features rather than managing complex data flows.

Additional Resources:

Top comments (1)

Collapse
 
radiantsolutions profile image
RadiantX solutions

superb✨