Nesse código usaremos os pacotes flutter_riverpod e infinite_scroll_pagination.
Recentemente, comecei a usar riverpod como meu gerenciador de estado.
No meu projeto eu utilizei o pacote infinite_scroll_pagination pra trabalhar com listas paginadas, e, para implementar cache na minha lista, eu precisava adicionar flutter_riverpod.
Antes de tudo começaremos agrupando todo o aplicativo em um ProviderScope:
class MyApp extends StatelessWidget {
final Widget child;
MyApp({super.key, required this.child});
@override
Widget build(BuildContext context) {
return ProviderScope(
child: FluentProvider(
child: MaterialApp(
title: 'Flutter Demo',
theme: theme,
home: child,
),
),
);
}
}
Repository
Criaremos a classe repositório e criaremos um Provider pra ela:
final usersRepositoryProvider = Provider((ref) => UsersRepository());
class UsersRepository {
final http.Client client = http.Client();
final apiBaseUri = Uri.parse("https://api.slingacademy.com/");
Future<Result<UsersPagedList<UserModel>>> fetchUsers(
int pageNumber,
) async {
try {
const int limit = 10;
final String offset = (pageNumber * limit).toString();
final response = await client.get(
apiBaseUri.replace(
path: "v1/sample-data/users",
queryParameters: {
"offset": offset,
},
),
);
if (response.statusCode != 200) {
switch (response.statusCode) {
case 404:
return Result.error(
'Recurso não encontrado. Verifique a URL ou tente novamente mais tarde',
);
case 500:
return Result.error('Erro Interno do Servidor.');
default:
return Result.error('Erro Http');
}
}
final jsonListResponse =
jsonDecode(response.body) as Map<String, Object?>;
final modelPagedList = UsersPagedList.fromJson(
jsonListResponse,
UserModel.fromJson,
);
return Result.value(modelPagedList);
} on ClientException {
return Result.error("Erro de conexão");
}
}
}
Pagination Controller
Vamos criar a classe RiverpodPaginationController. Começamos criando nosso pagingController assim como mostra na implementação da infinite scroll pagination. Usamos a função addPageRequestListener e chamamos nossa função de request no construtor.
class RiverpodPaginationController<T> {
final PagingController<int, T> pagingController =
PagingController(firstPageKey: 0);
RiverpodPaginationController(){
pagingController.addPageRequestListener(onPageRequest);
}
void onPageRequest(pageKey){
// request data here
}
}
Para que não esqueçamos de usar dispose no nosso controller, criaremos a classe abstrata ViewController e vamos fazer nossa pagination controller extender dela.
import 'package:flutter/material.dart';
abstract class ViewController<TState extends State> implements SimpleController {
final TState state;
ViewController(this.state);
}
abstract interface class SimpleController{
void dispose();
}
Agora implementamos a função dispose e damos dispose em nossa pagingController
:
@override
void dispose() {
pagingController.dispose();
}
Precisaremos passar dois parâmetros para nossa classe: o ref
(esse objeto nos ajuda a interagir com providers), e o nosso provider que busca os dados.
class RiverpodPaginationController extends ViewController {
final WidgetRef ref;
ProviderListenable<AsyncValue<UsersPagedList<UserModel>>> Function(
int pageKey,
) provider;
final PagingController<int, UserModel> pagingController =
PagingController(firstPageKey: 0);
RiverpodPaginationController(super.state, {
required this.ref,
required this.provider,
}) {
pagingController.addPageRequestListener(onPageRequest);
}
Vamos criar uma lista de ProviderListenable
e a cada chamada de página adicionamos um novo ProviderListenable, usando a função onPageRequest
que é chamada na nossa addPageRequestListener
.
final List<ProviderSubscription<AsyncValue<UsersPagedList<UserModel>>>>
subs = [];
Usaremos o ref.listenManual
onde passaremos o provider e uma função que vai ser executada toda vez que o valor do provider mudar, chamaremos essa função de handleState. Não esquecendo de passar fireImmediately
como true.
void onPageRequest(int pageKey) {
subs.add(
ref.listenManual(
provider(pageKey),
(previous, next) {
handleState(
pageInfo: next,
pageKey: pageKey,
previousState: previous,
);
},
fireImmediately: true,
),
);
}
Na nossa handleState
usaremos a função .when para lidar com o valor AsyncValue e seguimos a lógica de paginação do infiniteScrollPagination:
void handleState({
required AsyncValue<UsersPagedList<UserModel>>? previousState,
required AsyncValue<UsersPagedList<UserModel>> pageList,
required int pageKey,
}) async {
await pageList.when(
skipLoadingOnRefresh: true,
data: (pagedListData) async {
final List<UserModel> usersList = pagedListData.users;
final isLastPage = usersList.length < pagedListData.limit;
usersList.forEach((element) async {
final coverImageUrl = element.profile_picture;
await DefaultCacheManager().downloadFile(coverImageUrl);
});
if (isLastPage) {
pagingController.appendLastPage(usersList);
} else {
final nextPageKey = pageKey + 1;
pagingController.appendPage(usersList, nextPageKey);
}
},
error: (error, stack) {
pagingController.error = error;
},
loading: () {
print("Loading...");
},
);
}
View
Vamos criar nosso provider que busca os dados. Será um FutureProvider
com os modificadores .familly
(obtém um único provider com base em um parâmetro externo) e autoDispose
(para destruir o estado de um provider quando ele não está mais sendo utilizado).
Vamos usar ref.keepAlive
na primeira página pra que esses dados sejam guardados.
Assim como a documentação do riverpod nos mostra, podemos implementar um método de extensão para manter o estado ativo durante um periodo de tempo:
extension CacheForExtension on AutoDisposeRef<Object?> {
/// Keeps the provider alive for [duration].
void cacheFor(Duration duration) {
// Immediately prevent the state from getting destroyed.
final link = keepAlive();
// After duration has elapsed, we re-enable automatic disposal.
final timer = Timer(duration, link.close);
// Optional: when the provider is recomputed (such as with ref.watch),
// we cancel the pending timer.
onDispose(timer.cancel);
}
}
Então nosso provider fica assim:
final usersProvider = FutureProvider.autoDispose
.family<UsersPagedList<UserModel>, int>((ref, pageNumber) async {
/// Keeps the state alive for 10 seconds
final users = await ref.read(usersRepositoryProvider).fetchUsers(pageNumber);
if (pageNumber == 0) {
ref.keepAlive();
} else {
ref.cacheFor(const Duration(seconds: 10));
ref.onDispose(() {
print('Dispose');
});
}
return users.asFuture;
});
Na view usaremos o ConsumerStatefulWidget
(que é equivalente ao StateFullWidget, com a diferença que no State temos acesso ao objeto ref
) do riverpod, e a classe RiverpodPaginationController vai ser instanciada no initState:
class UsersView extends ConsumerStatefulWidget {
const UsersView({super.key});
@override
ConsumerState<UsersView> createState() => _UsersViewState();
}
class _UsersViewState extends ConsumerState<UsersView> {
late final RiverpodPaginationController paginationController;
@override
void initState() {
super.initState();
paginationController = RiverpodPaginationController(
this,
ref: ref,
provider: (pageKey) => usersProvider(pageKey),
);
}
@override
Widget build(BuildContext context) {
return FluentScaffold(
extendBodyBehindAppBar: true,
appBar: AppBar(
title: const Text(
"Users List",
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
actions: const [
Padding(
padding: EdgeInsets.only(right: 16),
child: Icon(Icons.info),
),
Padding(
padding: EdgeInsets.only(right: 16),
child: Icon(Icons.add_circle),
),
],
foregroundColor: Colors.white70,
backgroundColor: Colors.transparent,
),
body: Container(
padding: const EdgeInsets.only(top: 18),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0XFF273754),
Color(0XFF4b6696),
],
),
),
child: SafeArea(
child: Column(
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Padding(
padding: const EdgeInsets.only(bottom: 30, top: 16),
child: Row(
children: [
const SizedBox(width: 16),
Container(
decoration: BoxDecoration(
color: Colors.cyan,
borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.all(10),
child: const Text(
"Lorem Ipsum",
style: TextStyle(
fontSize: 18,
),
),
),
const SizedBox(width: 16),
Container(
decoration: BoxDecoration(
color: Colors.cyan,
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.all(10),
child: const Text(
"Lorem Ipsum",
style: TextStyle(
fontSize: 18,
),
),
),
const SizedBox(width: 16),
Container(
decoration: BoxDecoration(
color: Colors.cyan,
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.all(10),
child: const Text(
"Lorem Ipsum",
style: TextStyle(
fontSize: 18,
),
),
)
],
),
),
),
Expanded(
child: PagedListView(
pagingController: paginationController.pagingController,
padding: const EdgeInsets.symmetric(horizontal: 16),
builderDelegate: PagedChildBuilderDelegate<UserModel>(
animateTransitions: false,
itemBuilder: (context, item, index) {
return Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: const Color(0XFF57859b).withOpacity(0.7),
width: 0.5,
),
),
),
padding: const EdgeInsets.symmetric(vertical: 18),
child: Row(
children: [
FluentContainer(
cornerRadius: FluentCornerRadius.circle,
shadow: FluentThemeDataModel.of(context)
.fluentShadowTheme
?.shadow8,
width: 60,
height: 60,
child: Image.network(
item.profile_picture,
fit: BoxFit.cover,
),
),
const SizedBox(width: 20),
Expanded(
child: Container(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.first_name + item.last_name,
textAlign: TextAlign.start,
style: const TextStyle(
fontWeight: FontWeight.w600,
color: Colors.white,
fontSize: 18,
),
),
const SizedBox(
height: 4,
),
Text(
item.email,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 13,
color: Colors.white,
),
),
Row(
children: [
Icon(
FluentIcons.location_12_filled,
size: FluentSize.size120.value,
color: Colors.white60,
),
const SizedBox(
width: 4,
),
Text(
item.city,
style: const TextStyle(
fontSize: 13,
color: Colors.white60,
),
),
],
),
],
),
),
),
],
),
);
},
firstPageErrorIndicatorBuilder: (context) => Center(
child: FilledButton(
onPressed: () {
print("Should be refreshing");
paginationController.pagingController.refresh();
},
child: const Text(
"Tente Novamente",
),
),
),
newPageErrorIndicatorBuilder: (context) => Center(
child: FilledButton(
onPressed: () {
print("Should be refreshing");
paginationController.pagingController.refresh();
},
child: const Text(
"Tente Novamente",
),
),
),
),
),
),
],
),
),
),
);
}
}
Model
Essas são minhas Models:
- UserModel:
class UserModel {
final int id;
final String city;
final String email;
final String last_name;
final String first_name;
final String profile_picture;
UserModel({
required this.id,
required this.city,
required this.email,
required this.last_name,
required this.first_name,
required this.profile_picture,
});
factory UserModel.fromJson(Map<String, dynamic> json) {
return UserModel(
id: json["id"] as int,
city: json["city"].toString(),
email: json["email"].toString(),
last_name: json["last_name"].toString(),
first_name: json["first_name"].toString(),
profile_picture: json["profile_picture"].toString(),
);
}
}
- UsersPagedList:
class UsersPagedList<T> {
final bool success;
final String message;
final int total_users;
final int offset;
final int limit;
final List<T> users;
UsersPagedList.raw({
required this.success,
required this.total_users,
required this.message,
required this.offset,
required this.limit,
required this.users,
});
factory UsersPagedList.fromJson(
Map<String, Object?> jsonObject,
FromJsonObjectConstructor<T> constructor,
) {
return UsersPagedList.raw(
success: jsonObject["success"]! as bool,
total_users: jsonObject["total_users"]! as int,
message: jsonObject["message"].toString(),
offset: jsonObject["offset"]! as int,
limit: jsonObject["limit"]! as int,
users: (jsonObject["users"]! as List<dynamic>)
.cast<Map<String, Object?>>()
.map((jsonObject) {
return constructor(jsonObject);
}).toList(),
);
}
}