If we look at our categories_list.dart
file - it is getting a bit long. So let's make this lesson about refactoring some code into separate files. Here's what we will do:
- Move our
Model
into its own file - Move all of our API calls into a service file
- Move the Category Edit button into its own file (along with all the logic)
These actions will set us up for a more modular and maintainable codebase.
Moving Category Model
Let's start by moving our simplest code - the Category
model. Create a new file in the models
directory called category.dart
. Copy the Category
class from categories_list.dart
into this new file.
lib/models/category.dart
class Category { final int id; final String name; Category({required this.id, required this.name}); factory Category.fromJson(Map<String, dynamic> json) { return Category(id: json['id'], name: json['name']); }}
Then, remove the Category
class from categories_list.dart
and import it from the new file:
lib/screens/categories_list.dart
import 'package:flutter/material.dart';import 'package:http/http.dart' as http;import 'dart:convert';import 'package:laravel_api_flutter_app/models/category.dart'; class Category { final int id; final String name; Category({required this.id, required this.name}); factory Category.fromJson(Map<String, dynamic> json) { return Category(id: json['id'], name: json['name']); }} // ...
You can load up your application and see that everything works as expected. This moves the Category
class into its own file for reusability.
Moving API Calls
Next, we have two API calls on our Categories List widget. While it is acceptable to have them there, we still have an issue with repeating code. For example, we repeat the URL for each call. So let's create a Service file to handle these API calls:
lib/services/api.dart
import 'dart:convert';import 'package:http/http.dart' as http;import 'package:laravel_api_flutter_app/models/category.dart'; class ApiService { ApiService(); final String baseUrl = 'https://78ac-78-58-236-130.ngrok-free.app/';}
Then, we can move the API calls from categories_list.dart
into this new file:
lib/services/api.dart
// ... Future<List<Category>> fetchCategories() async { final http.Response response = await http.get(Uri.parse('$baseUrl/api/categories')); final Map<String, dynamic> data = json.decode(response.body); if (!data.containsKey('data') || data['data'] is! List) { throw Exception('Failed to load categories'); } List categories = data['data']; return categories.map((category) => Category.fromJson(category)).toList(); } Future saveCategory(id, name) async { String url = '$baseUrl/api/categories/$id'; final http.Response response = await http.put( Uri.parse(url), headers: <String, String>{ 'Content-Type': 'application/json; charset=UTF-8', }, body: jsonEncode(<String, String>{ 'name': name, }), ); if (response.statusCode != 200) { throw Exception('Failed to update category'); } } // ...
And finally, we can import the ApiService
class into categories_list.dart
and use it to make the API calls:
lib/screens/categories_list.dart
import 'package:flutter/material.dart';import 'package:http/http.dart' as http;import 'dart:convert';import 'package:laravel_api_flutter_app/models/category.dart'; class CategoriesList extends StatefulWidget { const CategoriesList({super.key}); @override CategoriesListState createState() => CategoriesListState();} class CategoriesListState extends State<CategoriesList> { Future<List<Category>>? futureCategories; ApiService apiService = ApiService(); final _formKey = GlobalKey<FormState>(); late Category selectedCategory; final categoryNameController = TextEditingController(); Future<List<Category>> fetchCategories() async { final http.Response response = await http.get( Uri.parse('https://ec90-78-58-236-130.ngrok-free.app/api/categories')); final Map<String, dynamic> data = json.decode(response.body); if (!data.containsKey('data') || data['data'] is! List) { throw Exception('Failed to load categories'); } List categories = data['data']; return categories.map((category) => Category.fromJson(category)).toList(); } Future saveCategory() async { final form = _formKey.currentState; if (!form!.validate()) { return; } String url = 'https://ec90-78-58-236-130.ngrok-free.app/api/categories/${selectedCategory.id}'; final http.Response response = await http.put( Uri.parse(url), headers: <String, String>{ 'Content-Type': 'application/json; charset=UTF-8', }, body: jsonEncode(<String, String>{ 'name': categoryNameController.text, }), ); Navigator.pop(context); } @override void initState() { super.initState(); futureCategories = fetchCategories(); futureCategories = apiService.fetchCategories(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('Categories List'), ), body: FutureBuilder<List<Category>>( future: futureCategories, builder: (context, snapshot) { if (snapshot.hasData) { return ListView.builder( itemCount: snapshot.data!.length, itemBuilder: (context, index) { Category category = snapshot.data![index]; return ListTile( title: Text(category.name), trailing: IconButton( onPressed: () { selectedCategory = category; categoryNameController.text = category.name; showModalBottomSheet( context: context, builder: (BuildContext context) { return Container( padding: EdgeInsets.all(20), child: Form( key: _formKey, child: Column( children: [ Text('Edit Category'), TextFormField( decoration: InputDecoration( labelText: 'Category Name', ), controller: categoryNameController, validator: (String? value) { if (value == null || value.isEmpty) { return 'Please enter category name'; } return null; }, ), Padding( padding: EdgeInsets.only(top: 20), child: ElevatedButton( onPressed: () { saveCategory(); apiService.saveCategory(selectedCategory.id, categoryNameController.text); }, child: Text('Update'), ), ), ], ), ), ); }); }, icon: Icon(Icons.edit)), ); }); } else if (snapshot.hasError) { return Text('${snapshot.error}'); } return CircularProgressIndicator(); }), ); }}
While this seems like a lot of changes, it will save us a lot of time and effort in future lessons, especially when we start adding more API calls to our application.
Moving Category Edit Button
The last thing to move is our Edit Category Button. This is a bit special, because it will show us how to move parts of our UI into separate files. Create a new file:
lib/widgets/category_edit.dart
import 'package:flutter/material.dart';import 'package:laravel_api_flutter_app/services/api.dart';import 'package:laravel_api_flutter_app/models/category.dart'; class CategoryEdit extends StatefulWidget { final Category category; const CategoryEdit(this.category, {super.key}); @override CategoryEditState createState() => CategoryEditState();} class CategoryEditState extends State<CategoryEdit> { @override Widget build(BuildContext context) { return Container(); }}
We have created a stateful widget, but there's more to it. We must pass a Category from our CategoriesList
widget to this new one.
This was done by adding two lines, just like we would do with __construct()
in PHP:
final Category category; const CategoryEdit(this.category, {super.key});
This will allow us to call CategoryEdit(category)
and pass the category
object to the new widget.
Next, we must transfer more code, including our Form
and TextFormField
widgets. We will also need to move the saveCategory
method:
lib/widgets/category_edit.dart
// ... class CategoryEditState extends State<CategoryEdit> { final GlobalKey<FormState> _formKey = GlobalKey<FormState>(); final categoryNameController = TextEditingController(); ApiService apiService = ApiService(); String errorMessage = ''; Future saveCategory(context) async { final form = _formKey.currentState; if (!form!.validate()) { return; } apiService .saveCategory(widget.category.id, categoryNameController.text) .then((dynamic response) => Navigator.pop(context)) .catchError((error) { setState(() { errorMessage = 'Failed to update category'; }); }); } @override void initState() { categoryNameController.text = widget.category.name; super.initState(); } // ...
Now, we have to move our UI code from CategoriesList
to CategoryEdit
:
Note: Biggest change is adding a Cancel button to the form.
lib/widgets/category_edit.dart
// ... @override Widget build(BuildContext context) { return Container( padding: EdgeInsets.all(20), child: Form( key: _formKey, child: Column( children: [ Text('Edit Category'), TextFormField( decoration: InputDecoration( labelText: 'Category Name', ), controller: categoryNameController, validator: (String? value) { if (value == null || value.isEmpty) { return 'Please enter category name'; } return null; }, onChanged: (String value) { setState(() { errorMessage = ''; }); }, ), Padding( padding: EdgeInsets.only(top: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ // We have also added a Cancel button ElevatedButton( onPressed: () => Navigator.pop(context), style: ElevatedButton.styleFrom( backgroundColor: Colors.red, foregroundColor: Colors.white, ), child: Text('Cancel'), ), ElevatedButton( onPressed: () { saveCategory(context); }, style: ElevatedButton.styleFrom( backgroundColor: Colors.purple, foregroundColor: Colors.white, ), child: Text('Update'), ), ], ), ), Text( errorMessage, style: TextStyle(color: Colors.red), ), ], ), ), );}
Once this is done, we can remove the code from CategoriesList
and import the new widget:
lib/screens/categories_list.dart
import 'package:laravel_api_flutter_app/widgets/category_edit.dart'; // ... return ListTile( title: Text(category.name), trailing: IconButton( onPressed: () { selectedCategory = category; categoryNameController.text = category.name; showModalBottomSheet( context: context, isScrollControlled: true, builder: (BuildContext context) { return Container( padding: EdgeInsets.all(20), child: Form( key: _formKey, child: Column( children: [ Text('Edit Category'), TextFormField( decoration: InputDecoration( labelText: 'Category Name', ), controller: categoryNameController, validator: (String? value) { if (value == null || value.isEmpty) { return 'Please enter category name'; } return null; }, ), Padding( padding: EdgeInsets.only(top: 20), child: ElevatedButton( onPressed: () { apiService.saveCategory(selectedCategory.id, categoryNameController.text); }, child: Text('Update'), ), ), ], ), ), return CategoryEdit(category); ); }); }, icon: Icon(Icons.edit)),); // ...
Your application should still work as it did before. The most significant change you will see now is that our Modal takes up the whole screen and has a Cancel button:
In the next lesson, we will focus on Real-time updates for our list to solve an issue with missing UI updates. This will provide a nicer user experience.
Check out the GitHub Commit for this lesson.
No comments yet…