Courses

[NEW] Flutter 3 Mobile App with Laravel 12 API

Adding Another CRUD - Transactions

In this lesson, we will apply all the things we have learned in the previous lesson and build a Transactions CRUD screen:


Creating a Model

Let's start by creating our new Transaction Model to match our Laravel Model and Database fields:

lib/models/transaction.dart

class Transaction {
int id;
int categoryId;
String categoryName;
String description;
String amount;
String transactionDate;
String createdAt;
 
Transaction(
{required this.id,
required this.categoryId,
required this.categoryName,
required this.description,
required this.amount,
required this.transactionDate,
required this.createdAt});
 
factory Transaction.fromJson(Map<String, dynamic> json) {
return Transaction(
id: json['id'],
categoryId: json['category_id'],
categoryName: json['category_name'],
description: json['description'],
amount: json['amount'],
transactionDate: json['transaction_date'],
createdAt: json['created_at'],
);
}
}

Adding API Functions

Next, we need to add a few functions to our API Service:

lib/services/api.dart

import 'package:laravel_api_flutter_app/models/transaction.dart';
 
// ...
 
 
Future<List<Transaction>> fetchTransactions() async {
http.Response response = await http.get(
Uri.parse(baseUrl + '/api/transactions'),
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer $token'
},
);
 
final Map<String, dynamic> data = json.decode(response.body);
 
if (!data.containsKey('data') || data['data'] is! List) {
throw Exception('Failed to load categories');
}
 
List transactions = data['data'];
 
return transactions
.map((transaction) => Transaction.fromJson(transaction))
.toList();
}
 
Future<Transaction> addTransaction(
String amount, String category, String description, String date) async {
String uri = baseUrl + '/api/transactions';
http.Response response = await http.post(Uri.parse(uri),
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer $token'
},
body: jsonEncode({
'amount': amount,
'category_id': category,
'description': description,
'transaction_date': date
}));
if (response.statusCode != 201) {
throw Exception('Error happened on create');
}
return Transaction.fromJson(jsonDecode(response.body)['data']);
}
 
Future<Transaction> updateTransaction(Transaction transaction) async {
String uri = baseUrl + '/api/transactions/' + transaction.id.toString();
http.Response response = await http.put(Uri.parse(uri),
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer $token'
},
body: jsonEncode({
'amount': transaction.amount,
'category_id': transaction.categoryId,
'description': transaction.description,
'transaction_date': transaction.transactionDate
}));
if (response.statusCode != 200) {
print(response.body);
throw Exception('Error happened on update');
}
return Transaction.fromJson(jsonDecode(response.body)['data']);
}
 
Future<void> deleteTransaction(id) async {
String uri = baseUrl + '/api/transactions/' + id.toString();
http.Response response = await http.delete(
Uri.parse(uri),
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer $token'
},
);
if (response.statusCode != 204) {
throw Exception('Error happened on delete');
}
}

Creating Transaction Provider

Once we have our API functions, we can work on creating a Provider:

lib/providers/transaction_provider.dart

import 'package:flutter/material.dart';
import 'package:laravel_api_flutter_app/models/transaction.dart';
import 'package:laravel_api_flutter_app/providers/auth_provider.dart';
import 'package:laravel_api_flutter_app/services/api.dart';
 
class TransactionProvider extends ChangeNotifier {
List<Transaction> transactions = [];
late ApiService apiService;
late AuthProvider authProvider;
 
TransactionProvider(AuthProvider authProvider) {
this.authProvider = authProvider;
init();
}
 
Future init() async {
this.apiService = ApiService(await authProvider.getToken());
transactions = await apiService.fetchTransactions();
notifyListeners();
}
 
Future<void> addTransaction(
String amount, String category, String description, String date) async {
try {
Transaction addedTransaction =
await apiService.addTransaction(amount, category, description, date);
transactions.add(addedTransaction);
notifyListeners();
} catch (e) {
print(e);
}
}
 
Future<void> updateTransaction(Transaction transaction) async {
try {
Transaction updatedTransaction =
await apiService.updateTransaction(transaction);
int index = transactions.indexOf(transaction);
transactions[index] = updatedTransaction;
notifyListeners();
} catch (e) {
print(e);
}
}
 
Future<void> deleteTransaction(Transaction transaction) async {
try {
await apiService.deleteTransaction(transaction.id);
transactions.remove(transaction);
notifyListeners();
} catch (e) {
print(e);
}
}
}

Installing Flutter Package

We are getting close to creating our interface. Let's install a package that will help us with the date picker:

flutter pub add intl

This package will help us format the date in a way that is easy to read, just like Carbon would in Laravel.


Creating Transactions List

We need to display a List of Transactions. We've taken our Categories List and modified it to display Transactions:

lib/screens/transactions/list.dart

import 'package:flutter/material.dart';
import 'package:laravel_api_flutter_app/models/transaction.dart';
import 'package:laravel_api_flutter_app/widgets/transaction_add.dart';
import 'package:laravel_api_flutter_app/widgets/transaction_edit.dart';
import 'package:provider/provider.dart';
import 'package:laravel_api_flutter_app/providers/transaction_provider.dart';
 
class Transactions extends StatefulWidget {
@override
_TransactionsState createState() => _TransactionsState();
}
 
class _TransactionsState extends State<Transactions> {
@override
Widget build(BuildContext context) {
final provider = Provider.of<TransactionProvider>(context);
List<Transaction> transactions = provider.transactions;
 
return Scaffold(
appBar: AppBar(
title: Text('Transactions'),
),
body: ListView.builder(
itemCount: transactions.length,
itemBuilder: (context, index) {
Transaction transaction = transactions[index];
return ListTile(
title: Text('\$' + transaction.amount),
subtitle: Text(transaction.categoryName),
trailing: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Text(transaction.transactionDate),
Text(transaction.description),
]),
IconButton(
icon: Icon(Icons.edit),
onPressed: () {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (BuildContext context) {
return TransactionEdit(
transaction, provider.updateTransaction);
});
},
),
IconButton(
icon: Icon(Icons.delete),
onPressed: () {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text("Confirmation"),
content: Text("Are you sure you want to delete?"),
actions: [
TextButton(
child: Text("Cancel"),
onPressed: () => Navigator.pop(context),
),
TextButton(
child: Text("Delete"),
onPressed: () => deleteTransaction(
provider.deleteTransaction, transaction, context)),
],
);
});
},
)
]),
);
},
),
floatingActionButton: new FloatingActionButton(
onPressed: () {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (BuildContext context) {
return TransactionAdd(provider.addTransaction);
});
},
child: Icon(Icons.add)),
);
}
 
Future deleteTransaction(Function callback, Transaction transaction, context) async {
await callback(transaction);
Navigator.pop(context);
}
}

Creating Add Transaction Screen

Then we have to do the same for our Transaction Add screen:

lib/widgets/transaction_add.dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:laravel_api_flutter_app/models/category.dart';
import 'package:provider/provider.dart';
import 'package:laravel_api_flutter_app/providers/category_provider.dart';
 
class TransactionAdd extends StatefulWidget {
final Function transactionCallback;
 
TransactionAdd(this.transactionCallback, {Key? key}) : super(key: key);
 
@override
_TransactionAddState createState() => _TransactionAddState();
}
 
class _TransactionAddState extends State<TransactionAdd> {
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
final transactionAmountController = TextEditingController();
final transactionCategoryController = TextEditingController();
final transactionDescriptionController = TextEditingController();
final transactionDateController = TextEditingController();
String errorMessage = '';
 
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(top: 50, left: 10, right: 10),
child: Form(
key: _formKey,
child: Column(children: <Widget>[
TextFormField(
controller: transactionAmountController,
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'^-?(\d+\.?\d{0,2})?')),
],
keyboardType: TextInputType.numberWithOptions(
signed: true, decimal: true),
decoration: InputDecoration(
border: OutlineInputBorder(),
labelText: 'Amount',
icon: Icon(Icons.attach_money),
hintText: '0',
),
validator: (value) {
if (value!.trim().isEmpty) {
return 'Amount is required';
}
final newValue = double.tryParse(value);
if (newValue == null) {
return 'Invalid amount format';
}
},
onChanged: (text) => setState(() => errorMessage = ''),
),
SizedBox(height: 20), // Acts as a spacer
buildCategoriesDropdown(),
SizedBox(height: 20), // Acts as a spacer
TextFormField(
controller: transactionDescriptionController,
decoration: InputDecoration(
border: OutlineInputBorder(),
labelText: 'Description',
),
validator: (value) {
if (value!.trim().isEmpty) {
return 'Description is required';
}
 
return null;
},
onChanged: (text) => setState(() => errorMessage = ''),
),
SizedBox(height: 20), // Acts as a spacer
TextFormField(
controller: transactionDateController,
onTap: () {
selectDate(context);
},
decoration: InputDecoration(
border: OutlineInputBorder(),
labelText: 'Transaction date',
),
validator: (value) {
if (value!.trim().isEmpty) {
return 'Date is required';
}
return null;
},
onChanged: (text) => setState(() => errorMessage = ''),
),
SizedBox(height: 20), // Acts as a spacer
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
ElevatedButton(
style:
ElevatedButton.styleFrom(backgroundColor: Colors.red, foregroundColor: Colors.white),
child: Text('Cancel'),
onPressed: () => Navigator.pop(context),
),
ElevatedButton(
child: Text('Save'),
onPressed: () => saveTransaction(context),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.purple,
foregroundColor: Colors.white
),
),
]),
Text(errorMessage, style: TextStyle(color: Colors.red))
])));
}
 
Future selectDate(BuildContext context) async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(DateTime.now().year - 5),
lastDate: DateTime(DateTime.now().year + 5));
if (picked != null)
setState(() {
transactionDateController.text =
DateFormat('MM/dd/yyyy').format(picked);
});
}
 
Widget buildCategoriesDropdown() {
return Consumer<CategoryProvider>(
builder: (context, cProvider, child) {
List<Category> categories = cProvider.categories;
return DropdownButtonFormField(
elevation: 8,
items: categories.map<DropdownMenuItem<String>>((e) {
return DropdownMenuItem<String>(
value: e.id.toString(),
child: Text(e.name,
style: TextStyle(color: Colors.black, fontSize: 20.0)));
}).toList(),
onChanged: (String? newValue) {
if (newValue == null) {
return;
}
setState(() {
transactionCategoryController.text = newValue.toString();
});
},
decoration: InputDecoration(
border: OutlineInputBorder(),
labelText: 'Category',
),
dropdownColor: Colors.white,
validator: (value) {
if (value == null) {
return 'Please select category';
}
},
);
},
);
}
 
Future saveTransaction(context) async {
final form = _formKey.currentState;
 
if (!form!.validate()) {
return;
}
 
await widget.transactionCallback(
transactionAmountController.text,
transactionCategoryController.text,
transactionDescriptionController.text,
transactionDateController.text);
Navigator.pop(context);
}
}

Creating Edit Transaction Screen

And for our Edit screen:

lib/widgets/transaction_edit.dart

 
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:laravel_api_flutter_app/models/category.dart';
import 'package:laravel_api_flutter_app/models/transaction.dart';
import 'package:provider/provider.dart';
import 'package:laravel_api_flutter_app/providers/category_provider.dart';
 
class TransactionEdit extends StatefulWidget {
final Transaction transaction;
final Function transactionCallback;
 
TransactionEdit(this.transaction, this.transactionCallback, {Key? key})
: super(key: key);
 
@override
_TransactionEditState createState() => _TransactionEditState();
}
 
class _TransactionEditState extends State<TransactionEdit> {
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
final transactionAmountController = TextEditingController();
final transactionCategoryController = TextEditingController();
final transactionDescriptionController = TextEditingController();
final transactionDateController = TextEditingController();
String errorMessage = '';
 
@override
void initState() {
transactionAmountController.text = widget.transaction.amount.toString();
transactionCategoryController.text =
widget.transaction.categoryId.toString();
transactionDescriptionController.text =
widget.transaction.description.toString();
transactionDateController.text =
widget.transaction.transactionDate.toString();
super.initState();
}
 
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(top: 50, left: 10, right: 10),
child: Form(
key: _formKey,
child: Column(children: <Widget>[
TextFormField(
controller: transactionAmountController,
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'^-?(\d+\.?\d{0,2})?')),
],
keyboardType: TextInputType.numberWithOptions(
signed: true, decimal: true),
decoration: InputDecoration(
border: OutlineInputBorder(),
labelText: 'Amount',
icon: Icon(Icons.attach_money),
hintText: '0',
),
validator: (value) {
if (value!.trim().isEmpty) {
return 'Amount is required';
}
final newValue = double.tryParse(value);
if (newValue == null) {
return 'Invalid amount format';
}
},
onChanged: (text) => setState(() => errorMessage = ''),
),
SizedBox(height: 20), // Acts as a spacer
buildCategoriesDropdown(),
SizedBox(height: 20), // Acts as a spacer
TextFormField(
controller: transactionDescriptionController,
decoration: InputDecoration(
border: OutlineInputBorder(),
labelText: 'Description',
),
validator: (value) {
if (value!.trim().isEmpty) {
return 'Description is required';
}
 
return null;
},
onChanged: (text) => setState(() => errorMessage = ''),
),
SizedBox(height: 20), // Acts as a spacer
TextFormField(
controller: transactionDateController,
onTap: () {
selectDate(context);
},
decoration: InputDecoration(
border: OutlineInputBorder(),
labelText: 'Transaction date',
),
validator: (value) {
if (value!.trim().isEmpty) {
return 'Date is required';
}
return null;
},
onChanged: (text) => setState(() => errorMessage = ''),
),
SizedBox(height: 20), // Acts as a spacer
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white),
child: Text('Cancel'),
onPressed: () => Navigator.pop(context),
),
ElevatedButton(
child: Text('Save'),
onPressed: () => saveTransaction(context),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.purple,
foregroundColor: Colors.white),
),
]),
Text(errorMessage, style: TextStyle(color: Colors.red))
])));
}
 
Future selectDate(BuildContext context) async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(DateTime.now().year - 5),
lastDate: DateTime(DateTime.now().year + 5));
if (picked != null)
setState(() {
transactionDateController.text =
DateFormat('MM/dd/yyyy').format(picked);
});
}
 
Widget buildCategoriesDropdown() {
return Consumer<CategoryProvider>(
builder: (context, cProvider, child) {
List<Category> categories = cProvider.categories;
 
return DropdownButtonFormField(
elevation: 8,
items: categories.map<DropdownMenuItem<String>>((e) {
return DropdownMenuItem<String>(
value: e.id.toString(),
child: Text(e.name,
style: TextStyle(color: Colors.black, fontSize: 20.0)));
}).toList(),
value: transactionCategoryController.text,
onChanged: (String? newValue) {
if (newValue == null) {
return;
}
 
setState(() {
transactionCategoryController.text = newValue.toString();
});
},
decoration: InputDecoration(
border: OutlineInputBorder(),
labelText: 'Category',
),
dropdownColor: Colors.white,
validator: (value) {
if (value == null) {
return 'Please select category';
}
},
);
},
);
}
 
Future saveTransaction(context) async {
final form = _formKey.currentState;
 
if (!form!.validate()) {
return;
}
 
widget.transaction.amount = transactionAmountController.text;
widget.transaction.categoryId =
int.parse(transactionCategoryController.text);
widget.transaction.description = transactionDescriptionController.text;
widget.transaction.transactionDate = transactionDateController.text;
 
await widget.transactionCallback(widget.transaction);
Navigator.pop(context);
}
}

Registering Transaction Provider

Once we have all the Screens and Widgets done, we need to register our Transaction Provider:

lib/main.dart

import 'package:flutter/material.dart';
import 'package:laravel_api_flutter_app/Screens/Auth/Login.dart';
import 'package:laravel_api_flutter_app/Screens/Auth/Register.dart';
import 'package:laravel_api_flutter_app/screens/categories/categories_list.dart';
import 'package:laravel_api_flutter_app/providers/transaction_provider.dart';
import 'package:laravel_api_flutter_app/providers/category_provider.dart';
import 'package:provider/provider.dart';
import 'package:laravel_api_flutter_app/screens/home.dart';
import 'package:laravel_api_flutter_app/providers/auth_provider.dart';
 
void main() {
runApp(MyApp());
}
 
class MyApp extends StatelessWidget {
const MyApp({super.key});
 
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => AuthProvider(),
child: Consumer<AuthProvider>(builder: (context, authProvider, child) {
return MultiProvider(
providers: [
ChangeNotifierProvider<CategoryProvider>(
create: (context) => CategoryProvider(authProvider)),
ChangeNotifierProvider<TransactionProvider>(
create: (context) => TransactionProvider(authProvider)),
],
child: MaterialApp(title: 'Welcome to Flutter', routes: {
'/': (context) {
final authProvider = Provider.of<AuthProvider>(context);
return authProvider.isAuthenticated ? Home() : Login();
},
'/login': (context) => Login(),
'/register': (context) => Register(),
'/home': (context) => Home(),
'/categories': (context) => CategoriesList(),
}));
}));
}
}

That's it! We have created a full CRUD for Transactions.


Fixing Token Issue

If we run the project now, we will get an error about our Token being String?:

lib/providers/transaction_provider.dart:17:34: Error: The argument type 'String?' can't be assigned to the parameter type 'String' because 'String?' is nullable and 'String' isn't.
this.apiService = ApiService(await authProvider.getToken());

So let's do some quick fixes to our getToken function:

lib/providers/auth_provider.dart

Future<String?> getToken() {
Future<String?> token = storage.read(key: 'token');
if (token != null) {
return Future.value(token);
}
 
return Future.value('');
}
 
Future<String> getToken() async {
try {
String? token = await storage.read(key: 'token');
if (token != null) {
return token ?? '';
}
 
return '';
} catch (e) {
return '';
}
}
 
Future<String?> setToken(String token) async {
Future<String> setToken(String token) async {
await storage.write(key: 'token', value: token);
 
return token;
}

We are simply changing the return type of the getToken function to Future<String> and returning an empty string if the token is not found. This will still fail our Sanctum middleware.

Now let's start our application again and test our Transactions CRUD:

Every CRUD action should now work as expected.


We will run Flutter checks in the next lesson to fix some code issues.


Check out the GitHub Commit for this lesson.

No comments yet…

avatar
You can use Markdown