Implementando Infinite Scroll Pagination com Riverpod
Tags: flutter, dart, riverpod, pagination
Published: 2024-02-14 20:00:10

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](https://pub.dev/packages/infinite_scroll_pagination) pra trabalhar com listas paginadas, e, para implementar cache na minha lista, eu precisava adicionar [flutter_riverpod](https://pub.dev/packages/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<T>**. 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

![My paginated list with riverpod](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/knaslw7n5oww4spo9i52.png)

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](https://riverpod.dev/docs/essentials/auto_dispose#example-keeping-state-alive-for-a-specific-amount-of-time) 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(),
    );
  }
}
```