Recently I was stumped across one of the project at Upwork that require to create app with Flutter using Riverpod state management with Repository design pattern as explained here.

Thus, it has sparked my curiosity since Riverpod state management itself not very popular compare with bLoC or getx. And here I am, tried to implement those requirement into simple Contact List App.
The UI will be quite simple, since that is not the main purpose for creating this app. Contact List App wire design :
HomePage -> consist of SearchBar, ListView and FloatingActionButton widget to display the ContactList data.
AddPage -> consist of form builder with save button as screen to add data or edit data.
DetailPage -> will be displayed when user clicked on row from ListView.
static final routes = {
RouteNames.homePage : (context) => const HomePage(title: 'Contact List App'),
RouteNames.addPage : (context) => const AddPage(),
RouteNames.detailPage : (context) => const DetailPage(),
};
To get proper mock up for this app, after completed the UI for 3 screen, Contact model data is created as follow :
class Contact {
int id = 0;
final String name;
final String surename;
final String email; Contact({
required this.id,
required this.name,
required this.surename,
required this.email,
});
Thus, I am able to put dummy Contact List data on the HomePage and the app will somewhat looks functional but without ability to save yet.
Next step, I must implement the logic for retrieving and saving data inside one repository file. I am using sqflite library for managing the database on local phone.
class ContactListRepository {
Database? _db;
Future<Database> get db async {
if (_db != null) {
return _db!;
} else {
_db = await initDb();
return _db!;
}
}
Future<Database> initDb() async {
final databasesPath = await getDatabasesPath();
final path = join(databasesPath, 'contact_list.db');
return await openDatabase(path, version: 1,
onCreate: (db, newerVersion) async {
await db.execute(
'CREATE TABLE $contactTable('
'$idColumn INTEGER PRIMARY KEY,'
'$nameColumn TEXT,'
'$emailColumn TEXT,'
'$surenameColumn TEXT)'
);
});
}
Future<Contact> saveContact(Contact contact) async {
var dbContact = await db;
contact.id = await dbContact.insert(contactTable, contact.toMap());
return contact;
}
Future<Contact?> getContact(int id) async {
var dbContact = await db;
List<Map<String, dynamic>> maps = await dbContact.query(contactTable,
columns: [idColumn, nameColumn, emailColumn, surenameColumn],
where: '$idColumn = ?',
whereArgs: [id]);
if (maps.isNotEmpty) {
return Contact.fromMap(maps.first);
} else {
return null;
}
}
Future<int> deleteContact(int id) async {
var dbContact = await db;
return await dbContact
.delete(contactTable, where: '$idColumn = ?', whereArgs: [id]);
}
Future<int> updateContact(Contact contact) async {
var dbContact = await db;
return await dbContact.update(contactTable, contact.toMap(),
where: '$idColumn = ?', whereArgs: [contact.id]);
}
Future<List<Contact>> getContactList(String filterText) async {
List<Contact> contactList = [];
var dbContact = await db;
List listMap = await dbContact.rawQuery('SELECT * FROM $contactTable');
if (filterText.isNotEmpty) {
List<Contact> list = [];
for (Map<String, dynamic> m in listMap) {
Contact contact = Contact.fromMap(m);
if ('${contact.name.toLowerCase()} ${contact.surename.toLowerCase()}'.contains(filterText.toLowerCase())){
list.add(contact);
}
}
return list;
} else {
for (Map<String, dynamic> m in listMap) {
Contact contact = Contact.fromMap(m);
contactList.add(contact);
}
return contactList;
}
}
}
By right, the app now should already has the ability to properly save and display contact using SQL database. Nonetheless, as final and important step, all data must be injected for each page using Riverpod and Provider. To be able to do this, I need to identify all data that need to be injected :
- ContactList data -> access for HomePage
- Selected data when user click for ListView -> access for DetailPage and AddPage
- FilterText on searchBar -> to regenerate ListView
final contactListControllerProvider = StateNotifierProvider.autoDispose<
ContactListController, AsyncValue<List<Contact>>>((ref) {
final contactListRepository = ref.watch(contactListRepositoryProvider);
final filterText = ref.watch(searchTextProvider);
return ContactListController(contactListRepository, filterText: filterText);
});
final selectedContactProvider = StateProvider<Contact?>((ref) {
return null;
});
final searchTextProvider = StateProvider<String>((ref) {
return '';
});
And also provide method as connector of UI to repository. All of this is listed inside ContactList controller.
class ContactListController extends StateNotifier<AsyncValue<List<Contact>>> {
final ContactListRepository _contactListRepository;
final String filterText;
ContactListController(this._contactListRepository, {required this.filterText})
: super(const AsyncValue.loading()) {
getContactList(filterText: filterText);
}
getContactList({required String filterText}) async {
try {
state = const AsyncValue.loading();
List<Contact> contactList = await _contactListRepository.getContactList(filterText);
state = AsyncValue.data(contactList);
} catch (e) {
state = AsyncValue.error(e);
}
}
saveContact({required Contact contact}) async {
await _contactListRepository.saveContact(contact);
await refreshList();
}
updateContact({required Contact contact}) async {
await _contactListRepository.updateContact(contact);
refreshList();
}
deleteContact({required Contact contact}) async {
await _contactListRepository.deleteContact(contact.id);
refreshList();
}
Future<void> refreshList() async {
List<Contact> contactList = await _contactListRepository.getContactList(filterText);
state = AsyncValue.data(contactList);
}
}
Finally those data can be accessed and injected for each screen using ConsumerWidget (for statelesswidget) or ConsumerStatefulWidget.
Full version for this app is here. I am very grateful if there is comment or correction for those who are more experience using this design pattern.