Courses

[NEW] Flutter 3 Mobile App with Laravel 12 API

Partial Widgets - Code Refactoring

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.

Previous: Editing Categories

No comments yet…

avatar
You can use Markdown