Adiciona Plano Bíblia em 180 Dias com lembrete diário e ícone do app

- Leitor bíblico consumindo a API NBV (bible.falehandix.com.br): lista de
  livros por testamento e leitura de capítulos
- Plano de 180 dias com três trilhas diárias (História, Sabedoria e
  Profetas, Novo Testamento) geradas deterministicamente sobre o cânon
  embutido (1.189 capítulos, cobertura testada)
- Lembrete diário via notificação local recorrente (UNCalendarNotificationTrigger,
  identificador fixo, sem duplicatas), com pedido de autorização em contexto,
  configurações de horário/som, e deep link da notificação para a Leitura de Hoje
- Estado do plano em SwiftData: progresso, pausa/retomada, reinício e
  conclusão preservando preferências do lembrete
- 15 testes unitários (Testing) e 2 testes de UI (XCUIAutomation)
- Ícone do app a partir do logo biblia.png

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 18:54:48 -03:00
parent 4d36df488f
commit 80185726af
19 changed files with 1583 additions and 110 deletions

View File

@@ -0,0 +1,114 @@
//
// ReadingPlanSchedule.swift
// Bible-Week
//
// Plano "Bíblia em 180 Dias": três trilhas paralelas por dia
// (História, Sabedoria e Profetas, Novo Testamento), distribuindo os
// capítulos de cada trilha uniformemente pelos 180 dias.
//
import Foundation
/// As três trilhas de leitura diária do plano.
enum PlanTrack: String, CaseIterable, Codable, Sendable {
case history = "history"
case wisdom = "wisdom"
case newTestament = "nt"
var displayName: String {
switch self {
case .history: "História"
case .wisdom: "Sabedoria e Profetas"
case .newTestament: "Novo Testamento"
}
}
/// Livros da trilha, em ordem canônica.
var books: [Book] {
switch self {
case .history: BibleCanon.books(in: 1...17) // Gênesis...Ester
case .wisdom: BibleCanon.books(in: 18...39) // Jó...Malaquias
case .newTestament: BibleCanon.books(in: 40...66) // Mateus...Apocalipse
}
}
}
/// Referência a um capítulo específico de um livro.
struct ChapterRef: Hashable, Sendable {
let bookCode: String
let bookName: String
let chapter: Int
}
/// Os capítulos de uma trilha em um dia do plano.
struct TrackReading: Hashable, Sendable, Identifiable {
let track: PlanTrack
let chapters: [ChapterRef]
var id: PlanTrack { track }
/// Rótulo legível, ex.: "Gênesis 3436" ou "Obadias 1 e Jonas 12".
var label: String {
// Agrupa capítulos consecutivos do mesmo livro em intervalos.
var runs: [(name: String, first: Int, last: Int)] = []
for ref in chapters {
if var last = runs.last, last.name == ref.bookName, last.last == ref.chapter - 1 {
last.last = ref.chapter
runs[runs.count - 1] = last
} else {
runs.append((ref.bookName, ref.chapter, ref.chapter))
}
}
let parts = runs.map { run in
run.first == run.last ? "\(run.name) \(run.first)" : "\(run.name) \(run.first)\(run.last)"
}
return parts.joined(separator: " e ")
}
}
/// A leitura completa de um dia (as três trilhas).
struct DayReading: Sendable {
let day: Int
let readings: [TrackReading]
func reading(for track: PlanTrack) -> TrackReading? {
readings.first { $0.track == track }
}
}
/// Cronograma fixo e determinístico do plano de 180 dias.
enum PlanSchedule {
static let totalDays = 180
static let days: [DayReading] = generate()
/// Leitura do dia `n` (1-based, limitado a 1...180).
static func day(_ n: Int) -> DayReading {
days[max(1, min(n, totalDays)) - 1]
}
private static func generate() -> [DayReading] {
let trackChapters: [PlanTrack: [ChapterRef]] = Dictionary(
uniqueKeysWithValues: PlanTrack.allCases.map { track in
let refs = track.books.flatMap { book in
(1...book.chapterCount).map {
ChapterRef(bookCode: book.code, bookName: book.name, chapter: $0)
}
}
return (track, refs)
}
)
return (0..<totalDays).map { index in
let readings = PlanTrack.allCases.map { track -> TrackReading in
let all = trackChapters[track]!
// Partição uniforme: o dia i recebe as posições
// [i*T/180, (i+1)*T/180), cobrindo tudo sem sobreposição.
let start = index * all.count / totalDays
let end = (index + 1) * all.count / totalDays
return TrackReading(track: track, chapters: Array(all[start..<end]))
}
return DayReading(day: index + 1, readings: readings)
}
}
}