Construindo um App de Dramas e Reels com Flutter 3.41: Riverpod, Feed Vertical e Descoberta (Parte 2)

Na primeira parte, estruturamos o banco de dados local ultrarrápido (Isar Community) e desenhamos a Home com Slivers. Agora, vamos dar vida ao aplicativo. Injetaremos dados reais usando Riverpod (uma das ferramentas de gerência de estado mais modernas e seguras do Flutter), criaremos o feed imersivo estilo TikTok para os Reels, estruturaremos a tela de exibição de Dramas (com guias de episódios) e completaremos as abas de navegação (Descubra, Minha Lista e Perfil).

💧 1. A Simbiose: Riverpod + Isar Community

Para conectar a UI ao Isar de forma reativa, usaremos o pacote flutter_riverpod. Ele garante que nossas listas de vídeos sejam reconstruídas apenas quando os dados no banco mudarem, economizando bateria e processamento.

Adicione no pubspec.yaml:

flutter_riverpod: ^2.5.1
video_player: ^2.8.2 # Para renderizar os vídeos depois

Criando os Providers Básicos (lib/core/providers/database_providers.dart):

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:isar/isar.dart';
import '../database/isar_service.dart';
import '../../features/dramas/models/video_content.dart';

// Provedor global do serviço do Isar
final isarServiceProvider = Provider<IsarService>((ref) => IsarService());

// Provedor Assíncrono para buscar todos os vídeos (ou filtrar)
final allVideosProvider = FutureProvider<List<VideoContent>>((ref) async {
  final isarService = ref.read(isarServiceProvider);
  final isar = await isarService.db;
  return isar.videoContents.where().findAll();
});

(Lembre-se de envolver o runApp com um ProviderScope no seu main.dart).

👤 2. Cadastro de Usuário (Gravando no Isar)

O usuário precisa ter um perfil local para salvar seus favoritos. Vamos criar uma tela simples de registro que salva os dados no schema UserProfile criado na Parte 1.

Arquivo: lib/features/profile/user_registration_screen.dart:

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/providers/database_providers.dart';
import 'models/user_profile.dart';

class UserRegistrationScreen extends ConsumerWidget {
  UserRegistrationScreen({super.key});

  final _nameController = TextEditingController();
  final _emailController = TextEditingController();

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      appBar: AppBar(title: const Text('Criar Meu Perfil')),
      body: Padding(
        padding: const EdgeInsets.all(24.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            const CircleAvatar(radius: 50, child: Icon(Icons.add_a_photo, size: 40)),
            const SizedBox(height: 32),
            TextField(controller: _nameController, decoration: const InputDecoration(labelText: 'Nome de Tela', border: OutlineInputBorder())),
            const SizedBox(height: 16),
            TextField(controller: _emailController, decoration: const InputDecoration(labelText: 'E-mail', border: OutlineInputBorder())),
            const Spacer(),
            FilledButton(
              onPressed: () async {
                final isarService = ref.read(isarServiceProvider);
                final isar = await isarService.db;
                
                final newUser = UserProfile()
                  ..id = 1 // ID fixo para single-user local
                  ..name = _nameController.text
                  ..email = _emailController.text
                  ..avatarUrl = ''; // Caminho da imagem ficaria aqui

                // Gravando no Isar (Operações de escrita devem ser síncronas)
                await isar.writeTxn(() async {
                  await isar.userProfiles.put(newUser);
                });
                
                Navigator.pop(context); // Volta para o Perfil
              },
              child: const Text('SALVAR PERFIL'),
            )
          ],
        ),
      ),
    );
  }
}

📱 3. O Feed Imersivo de Reels (PageView Vertical)

A magia do “estilo TikTok” está no PageView com rolagem vertical. Cada página ocupa a tela inteira e possui seu próprio controlador de vídeo.

Arquivo: lib/features/reels/reels_feed_screen.dart:

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'widgets/immersive_video_player.dart'; // Widget que controla o VideoPlayer

class ReelsFeedScreen extends ConsumerWidget {
  const ReelsFeedScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Busca apenas os vídeos que têm a flag isReel = true
    final reelsAsync = ref.watch(allVideosProvider); 

    return Scaffold(
      backgroundColor: Colors.black, // Fundo preto imersivo
      extendBodyBehindAppBar: true,
      appBar: AppBar(
        backgroundColor: Colors.transparent,
        elevation: 0,
        title: const Text('Reels', style: TextStyle(fontWeight: FontWeight.bold)),
      ),
      body: reelsAsync.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (err, stack) => Center(child: Text('Erro: $err')),
        data: (videos) {
          final reels = videos.where((v) => v.isReel).toList();
          
          return PageView.builder(
            scrollDirection: Axis.vertical, // O segredo da imersão
            itemCount: reels.length,
            itemBuilder: (context, index) {
              return ImmersiveVideoPlayer(videoUrl: reels[index].videoUrl);
            },
          );
        },
      ),
    );
  }
}

♻️ 4. Gestão de Memória no Vídeo (Lifecycle)

Colocar dezenas de instâncias de VideoPlayerController em um PageView causará Out Of Memory (OOM). Precisamos garantir que apenas o vídeo atual toque e que os antigos sejam descartados.

Dica Técnica: O widget ImmersiveVideoPlayer deve ser um StatefulWidget que inicializa o VideoPlayerController no initState e dá um .dispose() no dispose. O PageView.builder cuidará de destruir as páginas que saem da tela.


🎬 5. Tela de Dramas: Player e Guia de Episódios

Diferente do Reel, o Drama possui um player 16:9 no topo e uma lista detalhada abaixo. Implementaremos a regra visual exigida: a descrição base informando o total de capítulos.

Arquivo: lib/features/dramas/drama_detail_screen.dart:

class DramaDetailScreen extends StatelessWidget {
  final String title;
  final int totalEpisodes;

  const DramaDetailScreen({super.key, required this.title, required this.totalEpisodes});

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);

    return Scaffold(
      body: CustomScrollView(
        slivers: [
          // Player no Topo
          SliverAppBar(
            expandedHeight: 250,
            pinned: true,
            flexibleSpace: FlexibleSpaceBar(
              background: Container(
                color: Colors.black,
                child: const Center(child: Icon(Icons.play_circle_fill, size: 64, color: Colors.white)), // Mock do Player
              ),
            ),
          ),
          
          // Informações e Guia de Capítulos
          SliverToBoxAdapter(
            child: Padding(
              padding: const EdgeInsets.all(16.0),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(title, style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold)),
                  const SizedBox(height: 8),
                  // Descrição Base Exigida
                  Text(
                    'Total $totalEpisodes de episódios >',
                    style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.primary, fontWeight: FontWeight.bold),
                  ),
                  const SizedBox(height: 16),
                  const Text('Sinopse: Um herói improvável descobre segredos ocultos em uma cidade controlada por corporações.'),
                  const SizedBox(height: 24),
                  Row(
                    children: [
                      Expanded(child: FilledButton.icon(onPressed: () {}, icon: const Icon(Icons.play_arrow), label: const Text('Assistir T1:E1'))),
                      const SizedBox(width: 8),
                      IconButton.filledTonal(onPressed: () {}, icon: const Icon(Icons.download)),
                      IconButton.filledTonal(onPressed: () {}, icon: const Icon(Icons.add)), // Adicionar à Minha Lista
                    ],
                  ),
                  const SizedBox(height: 24),
                  const Divider(),
                ],
              ),
            ),
          ),

          // Lista de Episódios
          SliverList(
            delegate: SliverChildBuilderDelegate(
              (context, index) {
                return ListTile(
                  leading: Container(
                    width: 120, height: 80,
                    decoration: BoxDecoration(color: Colors.grey[800], borderRadius: BorderRadius.circular(8)),
                    child: const Icon(Icons.play_arrow, color: Colors.white54),
                  ),
                  title: Text('Episódio ${index + 1}'),
                  subtitle: const Text('45 min'),
                  onTap: () {}, // Troca o vídeo no player do topo
                );
              },
              childCount: totalEpisodes,
            ),
          ),
        ],
      ),
    );
  }
}

❤️ 6. A Tela “Minha Lista” (Favoritos e Histórico)

Esta tela exibe os conteúdos que o usuário marcou para ver depois ou já assistiu, usando consultas refinadas do Isar via Riverpod.

Provider Específico (minha_lista_provider.dart):

final minhaListaProvider = FutureProvider<List<VideoContent>>((ref) async {
  final isar = await ref.read(isarServiceProvider).db;
  // Query fluente do Isar: Filtra onde inMyList é verdadeiro OU foi assistido recentemente
  return isar.videoContents.filter()
      .inMyListEqualTo(true)
      .or()
      .lastWatchedIsNotNull()
      .findAll();
});

Para manter o alto nível técnico do nosso app de streaming, esta tela precisa lidar perfeitamente com os estados de carregamento (loading), erro e, principalmente, com o estado vazio (quando o usuário ainda não favoritou nada).

📺 6.1 A Tela “Minha Lista” (UI Completa)

Nesta tela, consumiremos o minhaListaProvider (que criamos no artigo anterior). Usaremos o método .when() do Riverpod, que nos obriga a tratar a interface para quando os dados estão carregando, deram erro ou chegaram com sucesso.

Para a exibição, um GridView.builder com 3 colunas entrega aquela sensação clássica de catálogo de streaming.

Arquivo: lib/features/my_list/minha_lista_screen.dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/providers/database_providers.dart';
import '../dramas/models/video_content.dart';

class MinhaListaScreen extends ConsumerWidget {
  const MinhaListaScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Escuta as mudanças no banco de dados Isar em tempo real
    final minhaListaAsync = ref.watch(minhaListaProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Minha Lista e Histórico'),
        centerTitle: false,
      ),
      // O .when() garante que trataremos todos os estados da requisição assíncrona
      body: minhaListaAsync.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (error, stackTrace) => Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              const Icon(Icons.error_outline, color: Colors.red, size: 48),
              const SizedBox(height: 16),
              Text('Erro ao carregar sua lista: $error'),
            ],
          ),
        ),
        data: (videos) {
          // Tratamento de Estado Vazio (Empty State)
          if (videos.isEmpty) {
            return Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Icon(Icons.video_library_outlined, size: 80, color: Theme.of(context).colorScheme.surfaceContainerHighest),
                  const SizedBox(height: 16),
                  Text(
                    'Sua lista está vazia',
                    style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
                  ),
                  const SizedBox(height: 8),
                  const Text('Adicione dramas e reels para assistir depois.'),
                ],
              ),
            );
          }

          // Grid de Vídeos Favoritados/Assistidos
          return GridView.builder(
            padding: const EdgeInsets.all(16),
            gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
              crossAxisCount: 3, // 3 Capas por linha
              childAspectRatio: 0.65, // Proporção vertical de pôster
              crossAxisSpacing: 12,
              mainAxisSpacing: 16,
            ),
            itemCount: videos.length,
            itemBuilder: (context, index) {
              final video = videos[index];
              return _buildVideoCard(context, video, ref);
            },
          );
        },
      ),
    );
  }

  // Widget extraído para manter o código limpo
  Widget _buildVideoCard(BuildContext context, VideoContent video, WidgetRef ref) {
    return GestureDetector(
      onTap: () {
        // Navegaria para a tela de detalhes do Drama ou pro Feed de Reels
        // context.push('/drama/${video.id}');
      },
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Expanded(
            child: Stack(
              fit: StackFit.expand,
              children: [
                // Capa do Vídeo
                Container(
                  decoration: BoxDecoration(
                    color: Theme.of(context).colorScheme.surfaceContainerHighest,
                    borderRadius: BorderRadius.circular(8),
                    image: DecorationImage(
                      image: NetworkImage(video.thumbnailUrl.isNotEmpty ? video.thumbnailUrl : 'https://via.placeholder.com/200x300'),
                      fit: BoxFit.cover,
                    ),
                  ),
                ),
                // Botão de remover da lista no canto superior direito
                Positioned(
                  top: 4,
                  right: 4,
                  child: InkWell(
                    onTap: () {
                      // Lógica para remover do Isar (Atualizar inMyList para false)
                      // Isso dispararia uma atualização automática no Riverpod!
                    },
                    child: Container(
                      padding: const EdgeInsets.all(4),
                      decoration: const BoxDecoration(color: Colors.black54, shape: BoxShape.circle),
                      child: const Icon(Icons.close, size: 16, color: Colors.white),
                    ),
                  ),
                ),
                // Indicador visual se for um Reel
                if (video.isReel)
                  const Positioned(
                    bottom: 4,
                    left: 4,
                    child: Icon(Icons.play_circle_outline, size: 20, color: Colors.white),
                  ),
              ],
            ),
          ),
          const SizedBox(height: 8),
          Text(
            video.title,
            maxLines: 2,
            overflow: TextOverflow.ellipsis,
            style: Theme.of(context).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.bold),
          ),
        ],
      ),
    );
  }
}

💡 O que torna este código poderoso?

  1. Reatividade Absoluta: Como estamos usando o ref.watch(minhaListaProvider), se o usuário for na tela “Descubra”, clicar em um filme e adicionar à lista (salvando no Isar), esta tela MinhaListaScreen será atualizada automaticamente assim que o usuário voltar para ela. Não precisamos de setState!

  2. Design Progressivo: O Empty State (estado vazio) é fundamental para a retenção do usuário. Em vez de uma tela em branco feia, mostramos um ícone e um texto amigável sugerindo que ele adicione conteúdos.

Com esse arquivo, você pode substituir o Placeholder() na lista _screens do MainLayout (no item 8 do artigo anterior) pela MinhaListaScreen().

🔍 7. Tela “Descubra”: Busca Avançada e Grid 2×2

A aba de exploração é onde o usuário encontra novos conteúdos. O layout exigido contém a barra de busca, categorias e um grid duplo de sugestões.

Arquivo: lib/features/discover/discover_screen.dart:

class DiscoverScreen extends StatelessWidget {
  const DiscoverScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Descubra')),
      body: CustomScrollView(
        slivers: [
          // 1. Busca na parte superior (Material 3 SearchBar)
          SliverToBoxAdapter(
            child: Padding(
              padding: const EdgeInsets.all(16.0),
              child: SearchBar(
                leading: const Icon(Icons.search),
                hintText: 'Buscar dramas, atores ou categorias...',
                onTap: () { /* Abre o SearchDelegate */ },
              ),
            ),
          ),

          // 2. Lista de Categorias (Horizontal)
          SliverToBoxAdapter(
            child: SizedBox(
              height: 50,
              child: ListView(
                scrollDirection: Axis.horizontal,
                padding: const EdgeInsets.symmetric(horizontal: 16),
                children: const [
                  ActionChip(label: Text('K-Dramas')), SizedBox(width: 8),
                  ActionChip(label: Text('Ação')), SizedBox(width: 8),
                  ActionChip(label: Text('Romance')), SizedBox(width: 8),
                  ActionChip(label: Text('Ficção')),
                ],
              ),
            ),
          ),

          const SliverToBoxAdapter(child: SizedBox(height: 24)),

          // 3. Grid de Vídeos pré-selecionados (2 a 2)
          SliverPadding(
            padding: const EdgeInsets.symmetric(horizontal: 16),
            sliver: SliverGrid(
              gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
                crossAxisCount: 2, // 2 Colunas (2 a 2)
                mainAxisSpacing: 16,
                crossAxisSpacing: 16,
                childAspectRatio: 0.7, // Proporção de pôster de filme
              ),
              delegate: SliverChildBuilderDelegate(
                (context, index) {
                  return Container(
                    decoration: BoxDecoration(
                      color: Theme.of(context).colorScheme.surfaceContainerHighest,
                      borderRadius: BorderRadius.circular(12),
                      image: const DecorationImage(
                        image: NetworkImage('https://via.placeholder.com/200x300'),
                        fit: BoxFit.cover,
                      ),
                    ),
                    alignment: Alignment.bottomLeft,
                    child: Container(
                      padding: const EdgeInsets.all(8),
                      decoration: const BoxDecoration(
                        gradient: LinearGradient(colors: [Colors.black87, Colors.transparent], begin: Alignment.bottomCenter, end: Alignment.topCenter),
                      ),
                      child: const Text('Título do Drama', style: TextStyle(fontWeight: FontWeight.bold)),
                    ),
                  );
                },
                childCount: 10, // Mock: viria do Riverpod
              ),
            ),
          ),
          const SliverToBoxAdapter(child: SizedBox(height: 32)),
        ],
      ),
    );
  }
}

🗺️ 8. Unificando a Navegação com NavigationBar (M3)

Para transitar entre a Home, Descubra, Reels, Minha Lista e Perfil, substituímos o antigo BottomNavigationBar pelo moderno NavigationBar do Material 3, que possui indicadores em formato de pílula.

Arquivo: lib/core/layouts/main_layout.dart:

class MainLayout extends StatefulWidget {
  const MainLayout({super.key});

  @override
  State<MainLayout> createState() => _MainLayoutState();
}

class _MainLayoutState extends State<MainLayout> {
  int _currentIndex = 0;
  
  final List<Widget> _screens = [
    const HomeScreen(),
    const DiscoverScreen(),
    const ReelsFeedScreen(),
    const Placeholder(), // MinhaListaScreen
    const Placeholder(), // ProfileScreen
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: _screens[_currentIndex],
      bottomNavigationBar: NavigationBar(
        selectedIndex: _currentIndex,
        onDestinationSelected: (index) => setState(() => _currentIndex = index),
        destinations: const [
          NavigationDestination(icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home), label: 'Início'),
          NavigationDestination(icon: Icon(Icons.search), label: 'Descubra'),
          NavigationDestination(icon: Icon(Icons.play_circle_outline), selectedIcon: Icon(Icons.play_circle_fill), label: 'Reels'),
          NavigationDestination(icon: Icon(Icons.bookmark_border), selectedIcon: Icon(Icons.bookmark), label: 'Minha Lista'),
          NavigationDestination(icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), label: 'Perfil'),
        ],
      ),
    );
  }
}

Conclusão da Parte 2

Neste artigo, transformamos uma casca visual em um aplicativo funcional e dinâmico:

  1. Integramos o Riverpod para consultar o banco Isar de forma limpa e assíncrona.

  2. Criamos o Registro de Usuário e mapeamos a tela “Minha Lista” baseada nas interações do banco NoSQL.

  3. Desenhamos duas telas cruciais de consumo de mídia: O player de Dramas (com a Guia de Capítulos) e o incrível Feed Vertical de Reels usando o PageView.

  4. Implementamos a aba de Descoberta usando SliverGrid e as maravilhas de layout do Material 3.

Please follow and like us:
error0
fb-share-icon
Tweet 20
fb-share-icon20