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…