DEV Community

Cover image for How to Build a CRUD Application Using Flutter & Strapi
Strapi for Strapi

Posted on • Originally published at strapi.io

How to Build a CRUD Application Using Flutter & Strapi

Introduction

Creating an application that requires a strong backend and a slick mobile frontend can be challenging when you are a mobile developer who has minimal backend development knowledge. Also, setting up a backend server, database, securing, and scaling can consume much of the productive time used in development. This is where Strapi comes in, providing headless backend service and helping you spin up a backend faster, allowing focus on building and launching apps faster.

In this tutorial, I'll show you how to build a full-featured mobile CRUD application with Flutter and Strapi 5.

Prerequisites

Before we jump in, ensure you've installed the following tool on your machine:

App Demo

Below is a demo of the project you'll build throughout this tutorial.

CRUD App with Flutter and Strapi Demo

Github Code

The full project code is available on my Github repository. The backend code is in the main branch, while the Flutter is in the mobile-app branch.

What's New in Strapi 5

Strapi 5 was just launched recently, and trust me, it's packed with some seriously cool upgrades. Let me break down the good stuff:

Enhanced Content Organisation

Do you recall how complicated it was to manage drafts earlier? Strapi 5 gives you two neat tabs, one for what you are working on and the other for what's live, no more content clutter. Also, the new history feature lets you return to any previous version. It's like ctrl+z for your whole content system. You can see exactly how your content will look on your site before it goes live—no more surprises after publishing.

Technical Improvements

The team has completely rewritten the core API with a new Document Service. It is so much faster, easier to use, and less messy than the old Entitiy Service. REST and GraphQL API both had a facelift—responses are more refined and easier to understand. For those who work with GraphQL, you'll love the new Relay-style query support. The new Strapi SDK makes it easier for developers to build custom plugins.

Improved Workflow

They've also introduced an AI chatbot that will recall the previous questions you asked, faster navigation in the docs with new tags that enable you to skip straight to the location you were interested in, and tons of guides and examples so you can build better stuff.

You can learn more about what is new for developers in Strapi 5: top 10 changes.

Setting Up the Backend with Strapi 5

Let's get your backend up and running. Open your terminal and run the following command to scaffold a new Strapi 5 project.

npx create-strapi-app@latest my-project --quickstart
cd my-project
Enter fullscreen mode Exit fullscreen mode

The above command will prompt you to select whether you want to Create a free account on Strapi Cloudor skip it for now. Then, the command will install the required project dependencies.

welcome to strapi.png

Creating the Content-Type

With your Strapi project created, Sign in to your admin panel.

Strapi admin dashboard.png

From the admin panel, click on the *Create your first content type * button to create a new content type called **Product. "

create product collection type.png

Then add the following fields and click on the Save button:

  • name (Text): Name of products on your app.
  • Description (Text): Description for each product.
  • price (Number): Price for products.
  • seller(One to Many relationship between User and Products):

product collection type and fields.png

Configure API Permissions

To allow your users access to the products from your Flutter application app, you need to configure your Strapi project permissions. Go to S*ettings → Users & Permissions plugin → Roles → Public* and enable the following permissions for Product:

  • find: Access to see all the products.
  • findOne: Access to view individual products.
  • create: Access to create a new product.
  • update: Access update products they created.
  • delete: Access to delete products they created.

enable api permission for product collection type.png

Next, locate the Users-permissions plugin, give the User collection, and give the Public role the following:

  • find: Access to see who the seller of the product is.
  • findOne: Access to the individual product seller's details.

enable api permission for user collection type.png

We are done with setting up your backend. Without any server setup, you created a fully functional CRUD application all of this without writing a single line of boilerplate code.

Now, you can focus on building your frontend, knowing that your backend is secure, scalable, and ready for production.

Testing the CRUD Endpoints

Let's test the backend using Postman to ensure everything works as expected. Send a GET request to the /api/products endpoint to get all products.

make request to fetch products.png

Repeat the steps to test the Create(POST request), Update(PUT request), and Delete endpoints.

Now, you are set to start integrating your Strapi backend with your Flutter application.

Building the Flutter Application

With your Strapi application fully configured, let's create the Flutter application. First, create a new Flutter project with the following command:

flutter create flutter_strapi_crud
cd flutter_strapi_crud
Enter fullscreen mode Exit fullscreen mode

Adding Dependencies

Once the project is successfully created, update your pubspec.yaml file to add the dio package. The dio package will allow you to make HTTP requests to your Strapi backend:

dependencies:
  flutter:
    sdk: flutter
  dio: ^5.4.0
Enter fullscreen mode Exit fullscreen mode

Now run the command flutter pub get to install the package new package you added.

Creating the Product Model

Next, create lib/models/product.dart file and add the code snippet below:

class Product {
  final int? id;
  final String? documentId;
  final String name;
  final String description;
  final double price;
  final DateTime? createdAt;
  final DateTime? updatedAt;
  final DateTime? publishedAt;

  Product({
    this.id,
    this.documentId,
    required this.name,
    required this.description,
    required this.price,
    this.createdAt,
    this.updatedAt,
    this.publishedAt,
  });

  factory Product.fromJson(Map<String, dynamic> json) {
    if (json['attributes'] != null) {
      final attributes = json['attributes'];
      return Product(
        id: json['id'],
        documentId: attributes['documentId'],
        name: attributes['name'],
        description: attributes['description'],
        price: double.parse(attributes['price'].toString()),
        createdAt: DateTime.parse(attributes['createdAt']),
        updatedAt: DateTime.parse(attributes['updatedAt']),
        publishedAt: DateTime.parse(attributes['publishedAt']),
      );
    }

    return Product(
      id: int.parse(json['id'].toString()),
      documentId: json['documentId'],
      name: json['name'],
      description: json['description'],
      price: double.parse(json['price'].toString()),
      createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null,
      updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt']) : null,
      publishedAt: json['publishedAt'] != null ? DateTime.parse(json['publishedAt']) : null,
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'name': name,
      'description': description,
      'price': price,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

The above model code defines our Product class with JSON serialization support. The @JsonSerializable() annotation and part directive tell the code generator to create the corresponding product.g.dart file, which will contain the implementation of the fromJson and toJson methods.

Now, generate this file by running the following command in your terminal:

flutter pub run build_runner build
Enter fullscreen mode Exit fullscreen mode

This will create the necessary serialization code for converting JSON data to Product objects and vice versa. If you change the model later, you must run this command again to regenerate the serialization code.

For faster development, you can use flutter pub run build_runner watch to automatically regenerate the code whenever you make changes.

Implementing the API Service

Now, let's create our API service to handle all our products' CRUD (Create, Read, Update, Delete) operations. This service will use Dio to make HTTP requests to our Strapi backend. Create lib/services/product_service.dart file and add the code snippet below:

import 'package:dio/dio.dart';
import 'package:flutter_strapi_crud/models/product.dart';

class ProductService {
  final Dio _dio;

  ProductService()
      : _dio = Dio(BaseOptions(
          baseUrl: 'http://localhost:1337/api',
          headers: {
            'Content-Type': 'application/json',
          },
        ));

  Future<List<Product>> getProducts() async {
    try {
      final response = await _dio.get('/products');
      final List<dynamic> data = response.data['data'];
      return data.map((item) => Product.fromJson(item)).toList();
    } catch (e) {
      throw 'Failed to load products: ${e.toString()}';
    }
  }

  Future<Product> createProduct(Product product) async {
    try {
      final response = await _dio.post('/products', data: {
        'data': {
          'name': product.name,
          'description': product.description,
          'price': product.price,
        }
      });
      return Product.fromJson(response.data['data']);
    } catch (e) {
      throw 'Failed to create product: ${e.toString()}';
    }
  }

  Future<Product> updateProduct(String id, Product product) async {
    try {
      print(id);
      final response = await _dio.put('/products/$id', data: {
        'data': {
          'name': product.name,
          'description': product.description,
          'price': product.price,
        }
      });
      return Product.fromJson(response.data['data']);
    } catch (e) {
      throw 'Failed to update product: ${e.toString()}';
    }
  }

  Future<void> deleteProduct(String id) async {
    try {
      await _dio.delete('/products/$id');
    } catch (e) {
      throw 'Failed to delete product: ${e.toString()}';
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The above code handles the CRUD (Create, Read, Update, Delete) operations using the Dio HTTP client for our Product model. The ProductService class is created with a baseURL set to the Android emulator IP address (10.0.2.2) and default headers set to accept the content type.

The service provides four main methods: getProducts(), which retrieves and deserializes products into a List<Product> createProduct(), which is a POST request for creating the products using the data entered updateProduct() which is a PUT request for updating existing products deleteProduct() which is a delete request for removing the products.

Creating the UI

Now, we need to create the main product screen, which will be responsible for showing the products and also for creating, updating, and even deleting products.

This screen will utilize our ProductService to communicate with the Strapi backend while offering easy functionality for the user to manage products. It supports form handling with TextEditingControllers, create/edit dialogs, and the swipe to-delete feature. Create lib/screens/product_screen.dart file and add the code snippet below:

import 'package:flutter/material.dart';
import 'package:flutter_strapi_crud/models/product.dart';
import 'package:flutter_strapi_crud/services/product_service.dart';

class ProductsScreen extends StatefulWidget {
  @override
  _ProductsScreenState createState() => _ProductsScreenState();
}

class _ProductsScreenState extends State<ProductsScreen> {
  final ProductService _productService = ProductService();
  late Future<List<Product>> _productsFuture;

  // Controllers for the form
  final _nameController = TextEditingController();
  final _descriptionController = TextEditingController();
  final _priceController = TextEditingController();

  @override
  void initState() {
    super.initState();
    _refreshProducts();
  }

  @override
  void dispose() {
    _nameController.dispose();
    _descriptionController.dispose();
    _priceController.dispose();
    super.dispose();
  }

  Future<void> _refreshProducts() async {
    setState(() {
      _productsFuture = _productService.getProducts();
    });
  }

  void _showProductForm({Product? product}) {
    // If editing, populate the controllers
    if (product != null) {
      _nameController.text = product.name;
      _descriptionController.text = product.description;
      _priceController.text = product.price.toString();
    } else {
      // Clear the controllers if creating new
      _nameController.clear();
      _descriptionController.clear();
      _priceController.clear();
    }

    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: Text(product == null ? 'Create Product' : 'Edit Product'),
        content: SingleChildScrollView(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              TextField(
                controller: _nameController,
                decoration: InputDecoration(labelText: 'Name'),
              ),
              TextField(
                controller: _descriptionController,
                decoration: InputDecoration(labelText: 'Description'),
                maxLines: 3,
              ),
              TextField(
                controller: _priceController,
                decoration: InputDecoration(labelText: 'Price'),
                keyboardType: TextInputType.number,
              ),
            ],
          ),
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: Text('Cancel'),
          ),
          TextButton(
            onPressed: () async {
              try {
                final newProduct = Product(
                  name: _nameController.text,
                  description: _descriptionController.text,
                  price: double.parse(_priceController.text),
                );

                if (product == null) {
                  await _productService.createProduct(newProduct);
                } else {
                  await _productService.updateProduct(
                      product.documentId!, newProduct);
                }

                Navigator.pop(context);
                _refreshProducts();
                _showSnackBar(
                    'Product ${product == null ? 'created' : 'updated'} successfully');
              } catch (e) {
                _showSnackBar(e.toString());
              }
            },
            child: Text(product == null ? 'Create' : 'Update'),
          ),
        ],
      ),
    );
  }

  Future<void> _deleteProduct(Product product) async {
    final confirm = await showDialog<bool>(
      context: context,
      builder: (context) => AlertDialog(
        title: Text('Delete Product'),
        content: Text('Are you sure you want to delete ${product.name}?'),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context, false),
            child: Text('Cancel'),
          ),
          TextButton(
            onPressed: () => Navigator.pop(context, true),
            child: Text('Delete'),
          ),
        ],
      ),
    );

    if (confirm == true) {
      try {
        await _productService.deleteProduct(product.documentId!);
        _refreshProducts();
        _showSnackBar('Product deleted successfully');
      } catch (e) {
        _showSnackBar(e.toString());
      }
    }
  }

  void _showSnackBar(String message) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text(message)),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Products'),
      ),
      body: RefreshIndicator(
        onRefresh: _refreshProducts,
        child: FutureBuilder<List<Product>>(
          future: _productsFuture,
          builder: (context, snapshot) {
            if (snapshot.connectionState == ConnectionState.waiting) {
              return Center(child: CircularProgressIndicator());
            }

            if (snapshot.hasError) {
              return Center(child: Text('Error: ${snapshot.error}'));
            }

            final products = snapshot.data!;

            return ListView.builder(
              itemCount: products.length,
              itemBuilder: (context, index) {
                final product = products[index];
                return Dismissible(
                    key: Key(product.id.toString()),
                    direction: DismissDirection.endToStart,
                    background: Container(
                      color: Colors.red,
                      alignment: Alignment.centerRight,
                      padding: EdgeInsets.only(right: 16),
                      child: Icon(Icons.delete, color: Colors.white),
                    ),
                    onDismissed: (_) => _deleteProduct(product),
                    child: ListTile(
                      title: Text(product.name),
                      subtitle: Text(
                        '${product.description}\nPrice: \$${product.price.toStringAsFixed(2)}',
                      ),
                      trailing: Row(
                        mainAxisSize: MainAxisSize
                            .min, // Makes the Row take minimum space
                        children: [
                          IconButton(
                            icon: Icon(Icons.edit),
                            onPressed: () => _showProductForm(product: product),
                          ),
                          IconButton(
                            icon: Icon(Icons.delete),
                            onPressed: () => _deleteProduct(product),
                          ),
                        ],
                      ),
                    ));
              },
            );
          },
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => _showProductForm(),
        child: Icon(Icons.add),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The following UI code creates a comprehensive product management screen in Flutter material design. The ProductsScreen is a StatefulWidget that stores the product data and employs the FutureBuilder for the asynchronous computations.

For form input management, create/edit forms, and delete confirmations, the widget uses TextEditingControllers and AlertDialogs; for delete functionality, it uses ListView.builder with Dismissible widgets. It uses RefreshIndicator for pull-to-refresh action, loading states with CircularProgressIndicator and user feedback with SnackBar notifications.

Now update your lib.main.dart file to render the ProductsScreen as the home screen:

import 'package:flutter_strapi_crud/screens/product_screen.dart';


//...

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      // Application name
      title: 'Flutter Hello World',
      // Application theme data, you can set the colors for the application as
      // you want
      theme: ThemeData(
        // useMaterial3: false,
        primarySwatch: Colors.blue,
      ),
      // A widget which will be started on application startup
      home: ProductsScreen(),
    );
  }
}

//...
Enter fullscreen mode Exit fullscreen mode

Testing the Application

Now, let's test your Flutter CRUD application. Start your Flutter application by running the following commands:

flutter run
Enter fullscreen mode Exit fullscreen mode

Then, create a new product using the floating action button.

create product.png

Then, view the list of products on the main screen.

product lists.png

Next, Edit a product by tapping the edit icon.

edit product.png

Lastly, delete a product by clicking the delete icon.

delete product.png

Conclusion

Glad you made it to the end of this tutorial. Throughout this tutorial, you've learned how to create a CRUD application using Strapi 5 and Flutter. You started by creating a new Strapi 5 project, creating a collection, and defining permissions. Then, you went further to create a new Flutter application, installed dependencies and created a model, service, and product screens to perform CRUD operation by sending API requests to the endpoints generated with Strapi.

To learn more about building a backend with Strapi 5, visit the Strapi documentation and the Flutter official documentation if you which to add more features to your application. Happy coding!

Top comments (1)

Collapse
 
joodi profile image
Joodi

Great ♥