Feature local-first de telemetria de leitura. Sem backend proprio: tudo vive no Drift. Alimenta tres surfaces user-facing:
- Stats dashboard (
/stats) — graficos dos ultimos 7/30 dias. - Monthly recap (
/stats/recap) — imagem compartilhavel 9:16 do mes corrente. - Book completion (
/books/:id/completion) — tela de conclusao com nota e card compartilhavel, disparada automaticamente ao chegar no final de um livro.
Uma sessao e um trecho continuo de leitura. Dois sabores:
- Playback (RSVP ou TTS): trecho continuo de
isPlaying=true. Comeca emplay(), termina empause()/ fim-do-livro / troca de modo / dispose do engine.seekToWorddurante play nao divide a sessao — palavras puladas pelo seek nao contam (_wordsInSessionso incrementa em_onTickno RSVP e em_onPlayerWordAdvanceno TTS). - Manual (ereader): abre em
enterEreaderMode()(ou no auto-restore do modo em_loadBook) e fecha emexitEreaderMode()/ troca de modo / play / dispose. As palavras vem dos seeks disparados pelo sync de posicao do scroll (ContextScrollView->seekToWord): deltas positivos ate_maxManualReadDelta(500) contam como lidos; deltas maiores sao navegacao (capitulo/slider) e nao contam; scroll pra tras nao subtrai. Sessoes manuais comavgWpm > 700sao descartadas como skimming (computeSessionAvgWpmcommanual: true).
Schema (lib/database/tables/reading_session_table.dart):
| Campo | Descricao |
|---|---|
id |
UUID |
bookId |
livro (sem FK — sobrevive a delete) |
startedAt / endedAt |
wall-clock |
durationMs |
tempo real do ticker (_elapsed.inMilliseconds) |
wordsRead |
_wordsInSession no momento do flush |
startWordIndex / endWordIndex |
cursor no inicio/fim |
avgWpm |
wordsRead * 60000 / durationMs, arredondado |
Indices em startedAt e bookId via @TableIndex (migration v5 cria).
computeSessionAvgWpm em rsvp_engine_provider.dart filtra sessoes com
durationMs < 3000 OU wordsRead < 5 — evita que taps acidentais no play
virem lixo nos graficos. Retorna null para dropar, numero para persistir.
Sessoes sobrevivem a delete do livro. O historico (e os recaps mensais) devem
continuar validos mesmo se o usuario limpar a biblioteca. Nos aggregates, livros
faltantes renderizam com titulo —.
lib/features/reading_stats/presentation/screens/reading_stats_screen.dart
hospeda um TabController Weekly/Monthly. Cada aba e alimentada por
statsSnapshotProvider(StatsRange) — StreamProvider.family que escuta
watchSessionsInRange(from, to) e junta com booksDao.getAllBooks().
Produto agregado consumido pelos widgets:
dailyBuckets: List<DailyBucket>— um por dia no range, comperBook(fatias coloridas no stacked chart) e totais.bookBreakdowns: List<BookBreakdown>— agregado por livro no range inteiro, ordenado desc portotalDurationMs.totalWords/totalDurationMs/avgWpm/booksTouched.
Toda a agregacao fica em buildSnapshot (pura, testada).
stats_words_per_day_chart.dart—BarChartcom stack por livro. Cores vem deStatsColorPalette.forBooks(orderedBookIds, scheme)— HSL rotation doscheme.primary, top 5 livros com cores distintas, resto colapsa em "Other" (scheme.outlineVariant).stats_time_per_day_chart.dart—BarChartsimples, minutos/dia.stats_wpm_trend_chart.dart—LineChartcom weighted avg WPM diario. Dias sem sessoes nao emitem spot (a linha conecta dias reais diretamente).
context.isTablet && context.isLandscape → duas colunas (summary+breakdown a esquerda, charts empilhados a direita). Caso contrario, single column scrollavel.
Acessado pelo botao recapGenerateCta no topo da aba Monthly.
monthlyRecapProvider(RecapMonth) agrega via aggregateByBookInRange (SQL
GROUP BY bookId com SUM/MAX/COUNT) e classifica cada livro em:
- Finalizados:
maxEndWordIndex >= totalWords - 1(cursor chegou na ultima palavra). - Em leitura: qualquer sessao no mes, nao finalizado.
A tela preview (MonthlyRecapScreen) envelopa o MonthlyRecapCard num
RepaintBoundary com GlobalKey + FittedBox(BoxFit.contain). Share chama
a função top-level shareWidgetAsPng() (em image_export_service.dart).
Disparo automatico: _advanceWord hit end-of-book incrementa
RsvpState.finishTicket. RsvpReaderScreen faz ref.listen comparando
next.finishTicket > prev.finishTicket e faz context.push dentro de
addPostFrameCallback (deixa o ultimo frame do RSVP word display estabilizar
antes de empurrar a tela celebratoria).
Por que finishTicket (contador) e nao bool didFinish:
- Contador permite re-disparo se usuario voltar e finalizar de novo apos seek.
- Um bool precisaria ser resetado explicitamente; contador so incrementa.
book_completion_screen.dart mostra, em ordem:
- Preview do
BookCompletionCardnoFittedBox. StarRatingPicker(0-5, tap na mesma estrela limpa). Cada mudanca persiste viabooksDao.updateRating(bookId, value)— schema v6 adicionou coluna nullablerating._StatsBlockemSectionCardcom tempo/palavras/sessoes/WPM medio.- Span de dias ("Concluido em X dias") se
firstSessionAt != lastSessionAt. SwitchListTile"Incluir estatisticas na imagem" → alternashowStatsno card (nao afeta a tela).- Share button.
Fixo 360×640 dp. Paleta independente de tema (ink-on-paper) pra
consistencia entre usuarios. Font families via GoogleFonts.lora() /
GoogleFonts.inter() — strings 'Lora'/'Inter' nao resolvem no PNG exportado
porque o app carrega fontes via package google_fonts, nao assets bundled.
Cover grande e central (220 com stats, 260 sem). Quando showStats = false,
rodape vira "Finalizado em {data}" via DateFormat.yMMMd(locale). Estrelas
so aparecem se rating != null.
lib/core/utils/image_export_service.dart:
shareWidgetAsPng({
required GlobalKey boundaryKey,
required String filename,
String? shareText,
double pixelRatio = 3.0,
}) {
await SchedulerBinding.instance.endOfFrame;
final boundary = key.currentContext!.findRenderObject() as RenderRepaintBoundary;
final image = await boundary.toImage(pixelRatio: pixelRatio);
...
SharePlus.instance.share(ShareParams(files: [XFile(...)], text: shareText));
}Pontos nao-obvios:
await endOfFrame: garante que o boundary tenha pintado ao menos um frame. Sem isso, a primeira captura pos-navegacao pode vir em branco.pixelRatio: 3.0sobre 360dp → ~1080px de largura real no PNG. Bom pra Stories/feed.FittedBoxenvolvendo o RepaintBoundary no preview: a captura usa o tamanho logico do child (360×640), nao o escalonado. Preview pequeno na tela, export em resolucao cheia.
- Ao adicionar schema Drift: bump
schemaVersionemapp_database.dart, adicionar blocoif (from < N)naonUpgrade. Rodardart run build_runner build --delete-conflicting-outputs. - Ao adicionar i18n no recap/completion cards: strings tem que resolver sem
Navigator/theme especifico (o card e capturado fora do fluxo normal de navegacao). - Ao mexer no engine: qualquer ponto novo de "saida do isPlaying=true" (nao so pause/end/ereader/dispose) deve chamar
_flushSession()antes de zerar contadores. Idem pra qualquer saida nova do modo ereader (sessao manual aberta) — e o flush deve rodar antes de mutarstate.mode, porque_flushSessionlestate.modepra decidir se aplica o cap de skimming manual. - Testes prioritarios:
computeSessionAvgWpm(threshold/arredondamento),buildSnapshot(bucketing + avgWpm ponderado),buildMonthlyRecap(classificacao finished/reading),buildCompletionSummary(totais + firstSessionAt/lastSessionAt).