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?
-
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 telaMinhaListaScreenserá atualizada automaticamente assim que o usuário voltar para ela. Não precisamos desetState! -
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:
-
Integramos o Riverpod para consultar o banco Isar de forma limpa e assíncrona.
-
Criamos o Registro de Usuário e mapeamos a tela “Minha Lista” baseada nas interações do banco NoSQL.
-
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. -
Implementamos a aba de Descoberta usando
SliverGride as maravilhas de layout do Material 3.