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,59 @@
//
// AppRouter.swift
// Bible-Week
//
// Navegação global do app e tratamento do toque em notificações:
// tocar no lembrete abre diretamente o Plano de Leitura Leitura de Hoje.
//
import Foundation
import Observation
import UserNotifications
@MainActor
@Observable
final class AppRouter {
enum Tab: Hashable {
case bible
case plan
}
var selectedTab: Tab = .bible
/// A tela principal do plano já é a "Leitura de Hoje".
func openTodayReading() {
selectedTab = .plan
}
}
/// Delegate do UNUserNotificationCenter. Precisa ser registrado no launch
/// (init do App) para capturar o toque na notificação mesmo com o app fechado.
final class NotificationCoordinator: NSObject, UNUserNotificationCenterDelegate {
private let router: AppRouter
init(router: AppRouter) {
self.router = router
}
/// Usuário tocou na notificação (app fechado, em background ou aberto).
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse
) async {
let userInfo = response.notification.request.content.userInfo
let destination = userInfo[NotificationUserInfo.destinationKey] as? String
guard destination == NotificationUserInfo.destinationReadingPlan else { return }
await MainActor.run {
router.openTodayReading()
}
}
/// Notificação chegou com o app em foreground: apresentação normal,
/// sem navegar automaticamente nem interromper uma leitura em andamento.
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
[.banner, .sound]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 763 KiB

View File

@@ -1,6 +1,7 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"

89
Bible-Week/BibleAPI.swift Normal file
View File

@@ -0,0 +1,89 @@
//
// BibleAPI.swift
// Bible-Week
//
// Cliente da API da Bíblia (NBV - Nova Bíblia Viva).
//
import Foundation
// MARK: - Modelos
/// Um livro da Bíblia (ex.: Gênesis, João).
struct Book: Decodable, Identifiable, Hashable {
let code: String
let name: String
let testament: String
let bookOrder: Int
let chapterCount: Int
var id: String { code }
enum CodingKeys: String, CodingKey {
case code, name, testament
case bookOrder = "book_order"
case chapterCount = "chapter_count"
}
}
/// Resposta de `GET /v1/books`.
struct BooksResponse: Decodable {
let books: [Book]
}
/// Um versículo com seu número e texto.
struct Verse: Decodable, Identifiable, Hashable {
let verse: Int
let text: String
var id: Int { verse }
}
/// Resposta de um capítulo completo.
struct Chapter: Decodable, Hashable {
let translation: String
let book: String
let bookName: String
let chapter: Int
let verses: [Verse]
}
// MARK: - Serviço
/// Cliente mínimo, somente leitura, para a API da Bíblia.
enum BibleAPI {
static let baseURL = URL(string: "https://bible.falehandix.com.br")!
// NOTA: para produção, mover para um Secrets.xcconfig (fora do controle de
// versão) ou para o Keychain. Mantido aqui apenas para a prova de conceito.
static let apiKey = "3717732ae41aa989ec7e0b30568784d78d23a8e026f5df889d82ae4b54f2986b"
/// Faz um GET na API e decodifica a resposta JSON.
static func get<T: Decodable>(_ path: String, query: [URLQueryItem] = []) async throws -> T {
var components = URLComponents(
url: baseURL.appendingPathComponent(path),
resolvingAgainstBaseURL: false
)!
if !query.isEmpty { components.queryItems = query }
var request = URLRequest(url: components.url!)
request.setValue(apiKey, forHTTPHeaderField: "X-API-Key")
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode else {
throw URLError(.badServerResponse)
}
return try JSONDecoder().decode(T.self, from: data)
}
/// Busca todos os 66 livros em ordem canônica.
static func books() async throws -> [Book] {
let response: BooksResponse = try await get("/v1/books")
return response.books
}
/// Busca o texto completo de um capítulo.
static func chapter(book code: String, chapter: Int) async throws -> Chapter {
try await get("/v1/books/\(code)/chapters/\(chapter)")
}
}

View File

@@ -0,0 +1,86 @@
//
// BibleCanon.swift
// Bible-Week
//
// Cânon protestante embutido (66 livros, 1.189 capítulos), espelhando
// exatamente os códigos e nomes retornados por GET /v1/books da API.
// Mantido local para o Plano de 180 Dias funcionar offline e ser testável.
//
import Foundation
enum BibleCanon {
static let books: [Book] = [
Book(code: "GEN", name: "Gênesis", testament: "OT", bookOrder: 1, chapterCount: 50),
Book(code: "EXO", name: "Êxodo", testament: "OT", bookOrder: 2, chapterCount: 40),
Book(code: "LEV", name: "Levítico", testament: "OT", bookOrder: 3, chapterCount: 27),
Book(code: "NUM", name: "Números", testament: "OT", bookOrder: 4, chapterCount: 36),
Book(code: "DEU", name: "Deuteronômio", testament: "OT", bookOrder: 5, chapterCount: 34),
Book(code: "JOS", name: "Josué", testament: "OT", bookOrder: 6, chapterCount: 24),
Book(code: "JDG", name: "Juízes", testament: "OT", bookOrder: 7, chapterCount: 21),
Book(code: "RUT", name: "Rute", testament: "OT", bookOrder: 8, chapterCount: 4),
Book(code: "1SA", name: "1 Samuel", testament: "OT", bookOrder: 9, chapterCount: 31),
Book(code: "2SA", name: "2 Samuel", testament: "OT", bookOrder: 10, chapterCount: 24),
Book(code: "1KI", name: "1 Reis", testament: "OT", bookOrder: 11, chapterCount: 22),
Book(code: "2KI", name: "2 Reis", testament: "OT", bookOrder: 12, chapterCount: 25),
Book(code: "1CH", name: "1 Crônicas", testament: "OT", bookOrder: 13, chapterCount: 29),
Book(code: "2CH", name: "2 Crônicas", testament: "OT", bookOrder: 14, chapterCount: 36),
Book(code: "EZR", name: "Esdras", testament: "OT", bookOrder: 15, chapterCount: 10),
Book(code: "NEH", name: "Neemias", testament: "OT", bookOrder: 16, chapterCount: 13),
Book(code: "EST", name: "Ester", testament: "OT", bookOrder: 17, chapterCount: 10),
Book(code: "JOB", name: "", testament: "OT", bookOrder: 18, chapterCount: 42),
Book(code: "PSA", name: "Salmos", testament: "OT", bookOrder: 19, chapterCount: 150),
Book(code: "PRO", name: "Provérbios", testament: "OT", bookOrder: 20, chapterCount: 31),
Book(code: "ECC", name: "Eclesiastes", testament: "OT", bookOrder: 21, chapterCount: 12),
Book(code: "SNG", name: "Cânticos", testament: "OT", bookOrder: 22, chapterCount: 8),
Book(code: "ISA", name: "Isaías", testament: "OT", bookOrder: 23, chapterCount: 66),
Book(code: "JER", name: "Jeremias", testament: "OT", bookOrder: 24, chapterCount: 52),
Book(code: "LAM", name: "Lamentações", testament: "OT", bookOrder: 25, chapterCount: 5),
Book(code: "EZK", name: "Ezequiel", testament: "OT", bookOrder: 26, chapterCount: 48),
Book(code: "DAN", name: "Daniel", testament: "OT", bookOrder: 27, chapterCount: 12),
Book(code: "HOS", name: "Oséias", testament: "OT", bookOrder: 28, chapterCount: 14),
Book(code: "JOL", name: "Joel", testament: "OT", bookOrder: 29, chapterCount: 3),
Book(code: "AMO", name: "Amós", testament: "OT", bookOrder: 30, chapterCount: 9),
Book(code: "OBA", name: "Obadias", testament: "OT", bookOrder: 31, chapterCount: 1),
Book(code: "JON", name: "Jonas", testament: "OT", bookOrder: 32, chapterCount: 4),
Book(code: "MIC", name: "Miquéias", testament: "OT", bookOrder: 33, chapterCount: 7),
Book(code: "NAM", name: "Naum", testament: "OT", bookOrder: 34, chapterCount: 3),
Book(code: "HAB", name: "Habacuque", testament: "OT", bookOrder: 35, chapterCount: 3),
Book(code: "ZEP", name: "Sofonias", testament: "OT", bookOrder: 36, chapterCount: 3),
Book(code: "HAG", name: "Ageu", testament: "OT", bookOrder: 37, chapterCount: 2),
Book(code: "ZEC", name: "Zacarias", testament: "OT", bookOrder: 38, chapterCount: 14),
Book(code: "MAL", name: "Malaquias", testament: "OT", bookOrder: 39, chapterCount: 4),
Book(code: "MAT", name: "Mateus", testament: "NT", bookOrder: 40, chapterCount: 28),
Book(code: "MRK", name: "Marcos", testament: "NT", bookOrder: 41, chapterCount: 16),
Book(code: "LUK", name: "Lucas", testament: "NT", bookOrder: 42, chapterCount: 24),
Book(code: "JHN", name: "João", testament: "NT", bookOrder: 43, chapterCount: 21),
Book(code: "ACT", name: "Atos", testament: "NT", bookOrder: 44, chapterCount: 28),
Book(code: "ROM", name: "Romanos", testament: "NT", bookOrder: 45, chapterCount: 16),
Book(code: "1CO", name: "1 Coríntios", testament: "NT", bookOrder: 46, chapterCount: 16),
Book(code: "2CO", name: "2 Coríntios", testament: "NT", bookOrder: 47, chapterCount: 13),
Book(code: "GAL", name: "Gálatas", testament: "NT", bookOrder: 48, chapterCount: 6),
Book(code: "EPH", name: "Efésios", testament: "NT", bookOrder: 49, chapterCount: 6),
Book(code: "PHP", name: "Filipenses", testament: "NT", bookOrder: 50, chapterCount: 4),
Book(code: "COL", name: "Colossenses", testament: "NT", bookOrder: 51, chapterCount: 4),
Book(code: "1TH", name: "1 Tessalonicenses", testament: "NT", bookOrder: 52, chapterCount: 5),
Book(code: "2TH", name: "2 Tessalonicenses", testament: "NT", bookOrder: 53, chapterCount: 3),
Book(code: "1TI", name: "1 Timóteo", testament: "NT", bookOrder: 54, chapterCount: 6),
Book(code: "2TI", name: "2 Timóteo", testament: "NT", bookOrder: 55, chapterCount: 4),
Book(code: "TIT", name: "Tito", testament: "NT", bookOrder: 56, chapterCount: 3),
Book(code: "PHM", name: "Filemom", testament: "NT", bookOrder: 57, chapterCount: 1),
Book(code: "HEB", name: "Hebreus", testament: "NT", bookOrder: 58, chapterCount: 13),
Book(code: "JAS", name: "Tiago", testament: "NT", bookOrder: 59, chapterCount: 5),
Book(code: "1PE", name: "1 Pedro", testament: "NT", bookOrder: 60, chapterCount: 5),
Book(code: "2PE", name: "2 Pedro", testament: "NT", bookOrder: 61, chapterCount: 3),
Book(code: "1JN", name: "1 João", testament: "NT", bookOrder: 62, chapterCount: 5),
Book(code: "2JN", name: "2 João", testament: "NT", bookOrder: 63, chapterCount: 1),
Book(code: "3JN", name: "3 João", testament: "NT", bookOrder: 64, chapterCount: 1),
Book(code: "JUD", name: "Judas", testament: "NT", bookOrder: 65, chapterCount: 1),
Book(code: "REV", name: "Apocalipse", testament: "NT", bookOrder: 66, chapterCount: 22),
]
/// Livros cujo `bookOrder` está no intervalo dado, em ordem canônica.
static func books(in range: ClosedRange<Int>) -> [Book] {
books.filter { range.contains($0.bookOrder) }
}
}

View File

@@ -7,26 +7,29 @@
import SwiftUI
import SwiftData
import UserNotifications
@main
struct Bible_WeekApp: App {
var sharedModelContainer: ModelContainer = {
let schema = Schema([
Item.self,
])
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
@State private var router: AppRouter
// Referência forte: o UNUserNotificationCenter guarda o delegate como weak.
private let notificationCoordinator: NotificationCoordinator
do {
return try ModelContainer(for: schema, configurations: [modelConfiguration])
} catch {
fatalError("Could not create ModelContainer: \(error)")
}
}()
init() {
let router = AppRouter()
let coordinator = NotificationCoordinator(router: router)
// Registrado no launch para o toque na notificação funcionar
// também quando o app estava fechado.
UNUserNotificationCenter.current().delegate = coordinator
self.notificationCoordinator = coordinator
self._router = State(initialValue: router)
}
var body: some Scene {
WindowGroup {
ContentView()
.environment(router)
}
.modelContainer(sharedModelContainer)
.modelContainer(for: ReadingPlanState.self)
}
}

View File

@@ -0,0 +1,93 @@
//
// BooksView.swift
// Bible-Week
//
// Aba Bíblia: lista os livros agrupados por testamento.
//
import SwiftUI
struct BooksView: View {
@State private var books: [Book] = []
@State private var isLoading = false
@State private var errorMessage: String?
/// Livros do Antigo Testamento, em ordem canônica.
private var oldTestament: [Book] {
books.filter { $0.testament == "OT" }.sorted { $0.bookOrder < $1.bookOrder }
}
/// Livros do Novo Testamento, em ordem canônica.
private var newTestament: [Book] {
books.filter { $0.testament == "NT" }.sorted { $0.bookOrder < $1.bookOrder }
}
var body: some View {
NavigationStack {
Group {
if isLoading {
ProgressView("Carregando livros...")
} else if let errorMessage {
ContentUnavailableView {
Label("Não foi possível carregar", systemImage: "wifi.slash")
} description: {
Text(errorMessage)
} actions: {
Button("Tentar novamente") { Task { await loadBooks() } }
}
} else {
bookList
}
}
.navigationTitle("Bíblia")
}
.task {
if books.isEmpty { await loadBooks() }
}
}
private var bookList: some View {
List {
Section("Antigo Testamento") {
ForEach(oldTestament) { book in
bookRow(book)
}
}
Section("Novo Testamento") {
ForEach(newTestament) { book in
bookRow(book)
}
}
}
.navigationDestination(for: Book.self) { book in
ChapterReaderView(book: book)
}
}
private func bookRow(_ book: Book) -> some View {
NavigationLink(value: book) {
HStack {
Text(book.name)
Spacer()
Text("\(book.chapterCount) cap.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
private func loadBooks() async {
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
books = try await BibleAPI.books()
} catch {
errorMessage = "Verifique sua conexão e tente novamente."
}
}
}
#Preview {
BooksView()
}

View File

@@ -0,0 +1,96 @@
//
// ChapterReaderView.swift
// Bible-Week
//
// Seleciona o capítulo e exibe o texto dos versículos.
//
import SwiftUI
struct ChapterReaderView: View {
let book: Book
@State private var selectedChapter = 1
@State private var chapter: Chapter?
@State private var isLoading = false
@State private var errorMessage: String?
var body: some View {
VStack(spacing: 0) {
chapterPicker
Divider()
content
}
.navigationTitle(book.name)
.navigationBarTitleDisplayMode(.inline)
.task(id: selectedChapter) {
await loadChapter()
}
}
/// Seletor horizontal com todos os capítulos do livro.
private var chapterPicker: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(1...book.chapterCount, id: \.self) { number in
Button {
selectedChapter = number
} label: {
Text("\(number)")
.font(.subheadline.weight(.medium))
.frame(minWidth: 36, minHeight: 36)
.background(
Circle()
.fill(number == selectedChapter ? Color.accentColor : Color(.secondarySystemBackground))
)
.foregroundStyle(number == selectedChapter ? .white : .primary)
}
}
}
.padding(.horizontal)
.padding(.vertical, 8)
}
}
@ViewBuilder
private var content: some View {
if isLoading {
Spacer()
ProgressView("Carregando capítulo...")
Spacer()
} else if let errorMessage {
Spacer()
ContentUnavailableView {
Label("Erro ao carregar", systemImage: "exclamationmark.triangle")
} description: {
Text(errorMessage)
} actions: {
Button("Tentar novamente") { Task { await loadChapter() } }
}
Spacer()
} else if let chapter {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
ForEach(chapter.verses) { verse in
Text("\(Text("\(verse.verse)").font(.caption2).foregroundColor(.accentColor).baselineOffset(4)) \(verse.text)")
.font(.body)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
private func loadChapter() async {
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
chapter = try await BibleAPI.chapter(book: book.code, chapter: selectedChapter)
} catch {
errorMessage = "Verifique sua conexão e tente novamente."
}
}
}

View File

@@ -2,79 +2,39 @@
// ContentView.swift
// Bible-Week
//
// Created by Matheus A Silveira on 04/09/26.
// Raiz do app: abas Bíblia (leitura livre) e Plano (Bíblia em 180 Dias).
//
import SwiftUI
import SwiftData
struct ContentView: View {
@Environment(AppRouter.self) private var router
@Environment(\.modelContext) private var modelContext
@Query private var items: [Item]
var body: some View {
NavigationViewWrapper {
List {
ForEach(items) { item in
NavigationLink {
Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
} label: {
Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
}
}
.onDelete(perform: deleteItems)
@Bindable var router = router
TabView(selection: $router.selectedTab) {
Tab("Bíblia", systemImage: "book", value: AppRouter.Tab.bible) {
BooksView()
}
#if os(macOS)
.navigationSplitViewColumnWidth(min: 180, ideal: 200)
#endif
.toolbar {
#if os(iOS)
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
#endif
ToolbarItem {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
Tab("Plano", systemImage: "calendar.badge.checkmark", value: AppRouter.Tab.plan) {
ReadingPlanView()
}
}
}
private func addItem() {
withAnimation {
let newItem = Item(timestamp: Date())
modelContext.insert(newItem)
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
for index in offsets {
modelContext.delete(items[index])
.task {
#if DEBUG
// Gancho para testes de UI: relança o app com o plano zerado.
if CommandLine.arguments.contains("--reset-plan") {
try? modelContext.delete(model: ReadingPlanState.self)
}
}
}
}
fileprivate struct NavigationViewWrapper<Content: View>: View {
let content: () -> Content
var body: some View {
#if os(macOS)
NavigationSplitView {
content()
} detail: {
Text("Select an item")
}
#else
content()
#endif
}
}
}
#Preview {
ContentView()
.modelContainer(for: Item.self, inMemory: true)
.environment(AppRouter())
.modelContainer(for: ReadingPlanState.self, inMemory: true)
}

View File

@@ -1,18 +0,0 @@
//
// Item.swift
// Bible-Week
//
// Created by Matheus A Silveira on 04/09/26.
//
import Foundation
import SwiftData
@Model
final class Item {
var timestamp: Date
init(timestamp: Date) {
self.timestamp = timestamp
}
}

View File

@@ -0,0 +1,150 @@
//
// PlanSettingsView.swift
// Bible-Week
//
// Configurações do Plano de 180 Dias: lembrete diário (horário, som),
// pausar/continuar e reiniciar.
//
import SwiftUI
import UserNotifications
struct PlanSettingsView: View {
@Bindable var plan: ReadingPlanState
@Environment(\.dismiss) private var dismiss
@State private var authorizationStatus: UNAuthorizationStatus = .notDetermined
@State private var showRestartConfirmation = false
var body: some View {
NavigationStack {
Form {
remindersSection
planSection
}
.navigationTitle("Plano de Leitura")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("OK") { dismiss() }
}
}
.task {
authorizationStatus = await ReadingReminderService.authorizationStatus()
}
.confirmationDialog(
"Reiniciar o plano do dia 1? Seu progresso atual será apagado, mas a preferência de lembrete será mantida.",
isPresented: $showRestartConfirmation,
titleVisibility: .visible
) {
Button("Reiniciar plano", role: .destructive) {
plan.restart()
Task { await ReadingReminderService.refresh(for: plan) }
}
}
}
}
// MARK: - Lembretes
@ViewBuilder
private var remindersSection: some View {
Section("Lembretes") {
if authorizationStatus == .denied {
VStack(alignment: .leading, spacing: 8) {
Text("Notificações desativadas")
.font(.headline)
Text("Para receber lembretes de leitura, habilite as notificações nos Ajustes do iPhone.")
.font(.caption)
.foregroundStyle(.secondary)
Button("Abrir Ajustes") {
openNotificationSettings()
}
}
.padding(.vertical, 4)
} else {
Toggle("Lembrete diário", isOn: reminderEnabledBinding)
if plan.reminderEnabled {
DatePicker(
"Horário",
selection: reminderTimeBinding,
displayedComponents: .hourAndMinute
)
Toggle("Som", isOn: soundEnabledBinding)
}
}
}
.accessibilityElement(children: .contain)
}
/// Ligar pede autorização se necessário; desligar cancela o agendamento.
private var reminderEnabledBinding: Binding<Bool> {
Binding {
plan.reminderEnabled
} set: { newValue in
if newValue {
Task {
let status = await ReadingReminderService.authorizationStatus()
var granted = status == .authorized || status == .provisional
if status == .notDetermined {
granted = (try? await ReadingReminderService.requestAuthorization()) ?? false
}
if granted {
plan.reminderEnabled = true
await ReadingReminderService.refresh(for: plan)
} else {
plan.reminderEnabled = false
authorizationStatus = .denied
}
}
} else {
plan.reminderEnabled = false
ReadingReminderService.cancelDailyReminder()
}
}
}
/// Alterar o horário substitui o agendamento anterior (mesmo identificador,
/// nunca duplica) e persiste o novo valor.
private var reminderTimeBinding: Binding<Date> {
Binding {
Calendar.current.date(
bySettingHour: plan.reminderHour,
minute: plan.reminderMinute,
second: 0,
of: .now
) ?? .now
} set: { newValue in
let components = Calendar.current.dateComponents([.hour, .minute], from: newValue)
plan.reminderHour = components.hour ?? 20
plan.reminderMinute = components.minute ?? 0
Task { await ReadingReminderService.refresh(for: plan) }
}
}
private var soundEnabledBinding: Binding<Bool> {
Binding {
plan.reminderSoundEnabled
} set: { newValue in
plan.reminderSoundEnabled = newValue
Task { await ReadingReminderService.refresh(for: plan) }
}
}
// MARK: - Plano
private var planSection: some View {
Section("Plano") {
LabeledContent("Progresso", value: "\(plan.completedDaysCount) de \(PlanSchedule.totalDays) dias")
Button(plan.isPaused ? "Continuar plano" : "Pausar plano") {
plan.isPaused.toggle()
// Pausar remove o agendamento sem apagar a preferência;
// continuar restaura automaticamente o horário salvo.
Task { await ReadingReminderService.refresh(for: plan) }
}
Button("Reiniciar plano", role: .destructive) {
showRestartConfirmation = true
}
}
}
}

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)
}
}
}

View File

@@ -0,0 +1,94 @@
//
// ReadingPlanState.swift
// Bible-Week
//
// Estado persistente do Plano Bíblia em 180 Dias: progresso de leitura
// e preferências do lembrete diário.
//
import Foundation
import SwiftData
@Model
final class ReadingPlanState {
var startedAt: Date
var isPaused: Bool
/// Chaves "dia-trilha" das leituras concluídas, ex.: "34-nt".
var completedKeys: [String]
// Preferências do lembrete diário. Sobrevivem a pausa e reinício do plano.
var reminderEnabled: Bool
var reminderHour: Int
var reminderMinute: Int
var reminderSoundEnabled: Bool
var hasSeenReminderPrompt: Bool
init(startedAt: Date = .now) {
self.startedAt = startedAt
self.isPaused = false
self.completedKeys = []
self.reminderEnabled = false
self.reminderHour = 20
self.reminderMinute = 0
self.reminderSoundEnabled = true
self.hasSeenReminderPrompt = false
}
// MARK: - Progresso
private static func key(day: Int, track: PlanTrack) -> String {
"\(day)-\(track.rawValue)"
}
func isCompleted(day: Int, track: PlanTrack) -> Bool {
completedKeys.contains(Self.key(day: day, track: track))
}
func setCompleted(_ completed: Bool, day: Int, track: PlanTrack) {
let key = Self.key(day: day, track: track)
if completed {
if !completedKeys.contains(key) { completedKeys.append(key) }
} else {
completedKeys.removeAll { $0 == key }
}
}
func isDayCompleted(_ day: Int) -> Bool {
PlanTrack.allCases.allSatisfy { isCompleted(day: day, track: $0) }
}
/// Primeiro dia com leitura pendente (1...180). Se tudo concluído, 180.
var currentDay: Int {
(1...PlanSchedule.totalDays).first { !isDayCompleted($0) } ?? PlanSchedule.totalDays
}
var completedDaysCount: Int {
(1...PlanSchedule.totalDays).count { isDayCompleted($0) }
}
var isCompleted: Bool {
completedDaysCount == PlanSchedule.totalDays
}
/// Fração concluída do plano (0...1).
var progress: Double {
Double(completedDaysCount) / Double(PlanSchedule.totalDays)
}
// MARK: - Lembrete
/// Horário do lembrete formatado, ex.: "20:00".
var reminderTimeText: String {
String(format: "%02d:%02d", reminderHour, reminderMinute)
}
// MARK: - Ciclo de vida do plano
/// Recomeça o plano do dia 1, preservando as preferências de lembrete.
func restart() {
completedKeys = []
startedAt = .now
isPaused = false
}
}

View File

@@ -0,0 +1,313 @@
//
// ReadingPlanView.swift
// Bible-Week
//
// Aba Plano: Bíblia em 180 Dias. Mostra a Leitura de Hoje com as três
// trilhas, progresso e o lembrete diário.
//
import SwiftUI
import SwiftData
struct ReadingPlanView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.scenePhase) private var scenePhase
@Query private var planStates: [ReadingPlanState]
@State private var showReminderPrompt = false
@State private var showSettings = false
private var plan: ReadingPlanState? { planStates.first }
var body: some View {
NavigationStack {
Group {
if let plan {
if plan.isCompleted {
completedView(plan)
} else if plan.isPaused {
pausedView(plan)
} else {
activeView(plan)
}
} else {
startView
}
}
.navigationTitle("Bíblia em 180 Dias")
.toolbar {
if plan != nil {
ToolbarItem(placement: .topBarTrailing) {
Button {
showSettings = true
} label: {
Label("Configurações do plano", systemImage: "gearshape")
}
}
}
}
.sheet(isPresented: $showReminderPrompt) {
if let plan {
ReminderPromptSheet(plan: plan)
}
}
.sheet(isPresented: $showSettings) {
if let plan {
PlanSettingsView(plan: plan)
}
}
}
// Ao voltar ao app, ressincroniza o agendamento (atualiza o número
// do dia no título da notificação e respeita pausa/conclusão).
.task(id: scenePhase) {
if scenePhase == .active, let plan {
await ReadingReminderService.refresh(for: plan)
}
}
}
// MARK: - Plano não iniciado
private var startView: some View {
VStack(spacing: 20) {
Image(systemName: "book.pages")
.font(.system(size: 56))
.foregroundStyle(.tint)
Text("Leia a Bíblia inteira em 180 dias")
.font(.title3.bold())
.multilineTextAlignment(.center)
Text("Todos os dias, uma leitura de História, uma de Sabedoria e Profetas e uma do Novo Testamento.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
Button("Começar plano") {
startPlan()
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
.padding(32)
}
private func startPlan() {
let state = ReadingPlanState()
modelContext.insert(state)
// Pedido de autorização acontece em contexto, não no launch:
// só depois que o usuário decidiu começar a jornada.
showReminderPrompt = true
}
// MARK: - Plano ativo
private func activeView(_ plan: ReadingPlanState) -> some View {
let today = PlanSchedule.day(plan.currentDay)
return List {
Section {
VStack(alignment: .leading, spacing: 12) {
Text("Dia \(plan.currentDay) de \(PlanSchedule.totalDays)")
.font(.title2.bold())
ProgressView(value: plan.progress) {
EmptyView()
} currentValueLabel: {
Text("\(Int((plan.progress * 100).rounded()))% concluído")
.font(.caption)
.foregroundStyle(.secondary)
}
reminderChip(plan)
}
.padding(.vertical, 4)
}
Section("Leitura de Hoje") {
ForEach(today.readings) { reading in
trackRow(reading, day: today.day, plan: plan)
}
}
}
}
private func reminderChip(_ plan: ReadingPlanState) -> some View {
Button {
// Alteração rápida: o chip leva direto às configurações.
showSettings = true
} label: {
HStack(spacing: 6) {
Image(systemName: plan.reminderEnabled ? "bell.fill" : "bell.slash")
Text(plan.reminderEnabled ? "Lembrete às \(plan.reminderTimeText)" : "Sem lembrete")
}
.font(.caption.weight(.medium))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Capsule().fill(Color(.secondarySystemBackground)))
}
.buttonStyle(.plain)
.accessibilityLabel(
plan.reminderEnabled
? "Lembrete diário ativado para \(plan.reminderHour) horas e \(plan.reminderMinute) minutos. Toque para alterar."
: "Lembrete diário desativado. Toque para configurar."
)
}
private func trackRow(_ reading: TrackReading, day: Int, plan: ReadingPlanState) -> some View {
let done = plan.isCompleted(day: day, track: reading.track)
return NavigationLink {
TrackReaderView(day: day, reading: reading, plan: plan)
} label: {
HStack(spacing: 12) {
Button {
toggleCompletion(reading, day: day, plan: plan)
} label: {
Image(systemName: done ? "checkmark.circle.fill" : "circle")
.font(.title3)
.foregroundStyle(done ? Color.accentColor : Color.secondary)
}
.buttonStyle(.borderless)
.accessibilityLabel(done ? "\(reading.track.displayName) concluída. Toque para desmarcar." : "Marcar \(reading.track.displayName) como concluída")
VStack(alignment: .leading, spacing: 2) {
Text(reading.track.displayName)
.font(.caption)
.foregroundStyle(.secondary)
Text(reading.label)
.strikethrough(done)
.foregroundStyle(done ? .secondary : .primary)
}
}
}
}
private func toggleCompletion(_ reading: TrackReading, day: Int, plan: ReadingPlanState) {
let done = plan.isCompleted(day: day, track: reading.track)
withAnimation {
plan.setCompleted(!done, day: day, track: reading.track)
}
if plan.isCompleted {
// 180/180: não continuar lembrando depois que o usuário terminou.
ReadingReminderService.cancelDailyReminder()
}
}
// MARK: - Plano pausado
private func pausedView(_ plan: ReadingPlanState) -> some View {
ContentUnavailableView {
Label("Plano pausado", systemImage: "pause.circle")
} description: {
Text("Seu progresso está guardado: \(plan.completedDaysCount) de \(PlanSchedule.totalDays) dias concluídos.")
} actions: {
Button("Continuar plano") {
plan.isPaused = false
Task { await ReadingReminderService.refresh(for: plan) }
}
.buttonStyle(.borderedProminent)
}
}
// MARK: - Plano concluído
private func completedView(_ plan: ReadingPlanState) -> some View {
ContentUnavailableView {
Label("Você concluiu a Bíblia!", systemImage: "checkmark.seal.fill")
} description: {
Text("180 de 180 dias. Parabéns pela jornada completa pela Palavra.")
} actions: {
Button("Reiniciar plano") {
plan.restart()
Task { await ReadingReminderService.refresh(for: plan) }
}
.buttonStyle(.borderedProminent)
}
}
}
// MARK: - Prompt contextual do lembrete
/// Apresentado uma única vez, logo após o usuário iniciar o plano.
/// A autorização do iOS só é pedida quando ele toca em "Ativar lembrete".
struct ReminderPromptSheet: View {
@Bindable var plan: ReadingPlanState
@Environment(\.dismiss) private var dismiss
@State private var time: Date = Calendar.current.date(bySettingHour: 20, minute: 0, second: 0, of: .now) ?? .now
@State private var showDeniedInfo = false
var body: some View {
VStack(spacing: 20) {
Image(systemName: "bell.badge")
.font(.system(size: 44))
.foregroundStyle(.tint)
.padding(.top, 24)
Text("Quer receber um lembrete diário?")
.font(.title3.bold())
.multilineTextAlignment(.center)
Text("Reserve um momento todos os dias para continuar sua jornada pela Bíblia.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
DatePicker("Horário", selection: $time, displayedComponents: .hourAndMinute)
.datePickerStyle(.wheel)
.labelsHidden()
if showDeniedInfo {
VStack(spacing: 8) {
Text("Notificações desativadas")
.font(.headline)
Text("Para receber lembretes de leitura, habilite as notificações nos Ajustes do iPhone.")
.font(.caption)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
Button("Abrir Ajustes") {
openNotificationSettings()
}
}
} else {
Button("Ativar lembrete") {
activateReminder()
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
Button("Agora não") {
plan.hasSeenReminderPrompt = true
dismiss()
}
.foregroundStyle(.secondary)
}
.padding(.horizontal, 24)
.padding(.bottom, 16)
.presentationDetents([.large])
.interactiveDismissDisabled(false)
}
private func activateReminder() {
plan.hasSeenReminderPrompt = true
let components = Calendar.current.dateComponents([.hour, .minute], from: time)
plan.reminderHour = components.hour ?? 20
plan.reminderMinute = components.minute ?? 0
Task {
let granted = (try? await ReadingReminderService.requestAuthorization()) ?? false
if granted {
plan.reminderEnabled = true
await ReadingReminderService.refresh(for: plan)
dismiss()
} else {
plan.reminderEnabled = false
showDeniedInfo = true
}
}
}
}
/// Abre os ajustes de notificação do app usando o mecanismo oficial do iOS.
@MainActor
func openNotificationSettings() {
guard let url = URL(string: UIApplication.openNotificationSettingsURLString) else { return }
UIApplication.shared.open(url)
}
#Preview {
ReadingPlanView()
.environment(AppRouter())
.modelContainer(for: ReadingPlanState.self, inMemory: true)
}

View File

@@ -0,0 +1,111 @@
//
// ReadingReminderService.swift
// Bible-Week
//
// Lembrete diário do Plano de 180 Dias via notificações locais.
// Uma única notificação recorrente (UNCalendarNotificationTrigger) com
// identificador fixo nunca 180 notificações individuais.
//
import Foundation
import UserNotifications
enum NotificationIdentifier {
static let readingPlanDailyReminder = "reading-plan-daily-reminder"
}
enum NotificationUserInfo {
static let destinationKey = "destination"
static let destinationReadingPlan = "readingPlan"
static let planIdKey = "planId"
static let planId = "bible-180"
static let screenKey = "screen"
static let screenToday = "today"
}
enum ReadingReminderService {
/// Mensagens que incentivam sem pressionar (nada de "streak perdido").
static let motivationalMessages = [
"Separe alguns minutos para a Palavra hoje.",
"Continue sua jornada pela Bíblia.",
"Um capítulo de cada vez. Vamos continuar?",
"Sua leitura de hoje está esperando por você.",
"Reserve este momento para sua leitura.",
"Continue de onde você parou.",
]
// MARK: - Autorização
static func authorizationStatus() async -> UNAuthorizationStatus {
await UNUserNotificationCenter.current().notificationSettings().authorizationStatus
}
/// Pede autorização ao iOS. Apenas alerta e som badge não é usado.
static func requestAuthorization() async throws -> Bool {
try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound])
}
// MARK: - Conteúdo
/// Monta o conteúdo do lembrete. Com `currentDay`, personaliza o título
/// com o dia da jornada; sem ele, usa o título padrão.
static func makeContent(currentDay: Int?, soundEnabled: Bool) -> UNMutableNotificationContent {
let content = UNMutableNotificationContent()
if let currentDay {
content.title = "📖 Dia \(currentDay) da sua jornada"
} else {
content.title = "📖 Sua leitura de hoje"
}
content.body = motivationalMessages.randomElement()
?? "Continue sua jornada pela Bíblia. Sua leitura de hoje está esperando por você."
content.sound = soundEnabled ? .default : nil
content.userInfo = [
NotificationUserInfo.destinationKey: NotificationUserInfo.destinationReadingPlan,
NotificationUserInfo.planIdKey: NotificationUserInfo.planId,
NotificationUserInfo.screenKey: NotificationUserInfo.screenToday,
]
return content
}
// MARK: - Agendamento
/// Agenda (ou substitui) o lembrete diário recorrente. Usar sempre o
/// mesmo identificador garante que nunca haverá duplicatas.
static func scheduleDailyReminder(hour: Int, minute: Int, soundEnabled: Bool, currentDay: Int?) async throws {
let content = makeContent(currentDay: currentDay, soundEnabled: soundEnabled)
var components = DateComponents()
components.hour = hour
components.minute = minute
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true)
let request = UNNotificationRequest(
identifier: NotificationIdentifier.readingPlanDailyReminder,
content: content,
trigger: trigger
)
try await UNUserNotificationCenter.current().add(request)
}
static func cancelDailyReminder() {
UNUserNotificationCenter.current().removePendingNotificationRequests(
withIdentifiers: [NotificationIdentifier.readingPlanDailyReminder]
)
}
/// Sincroniza o agendamento com o estado do plano: agenda se o lembrete
/// está habilitado, o plano ativo e a permissão concedida; caso contrário
/// cancela. Também atualiza o número do dia no título ao reagendar.
@MainActor
static func refresh(for plan: ReadingPlanState) async {
let shouldSchedule = plan.reminderEnabled && !plan.isPaused && !plan.isCompleted
guard shouldSchedule, await authorizationStatus() == .authorized else {
cancelDailyReminder()
return
}
try? await scheduleDailyReminder(
hour: plan.reminderHour,
minute: plan.reminderMinute,
soundEnabled: plan.reminderSoundEnabled,
currentDay: plan.currentDay
)
}
}

View File

@@ -0,0 +1,113 @@
//
// TrackReaderView.swift
// Bible-Week
//
// Leitura de uma trilha do dia: carrega os capítulos em sequência
// e permite marcar a trilha como concluída.
//
import SwiftUI
struct TrackReaderView: View {
let day: Int
let reading: TrackReading
@Bindable var plan: ReadingPlanState
@State private var chapters: [Chapter] = []
@State private var isLoading = false
@State private var errorMessage: String?
private var isCompleted: Bool {
plan.isCompleted(day: day, track: reading.track)
}
var body: some View {
Group {
if isLoading {
ProgressView("Carregando leitura...")
} else if let errorMessage {
ContentUnavailableView {
Label("Erro ao carregar", systemImage: "exclamationmark.triangle")
} description: {
Text(errorMessage)
} actions: {
Button("Tentar novamente") { Task { await load() } }
}
} else {
content
}
}
.navigationTitle(reading.label)
.navigationBarTitleDisplayMode(.inline)
.safeAreaInset(edge: .bottom) {
markButton
}
.task { await load() }
}
private var content: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 16) {
ForEach(chapters, id: \.self) { chapter in
Text("\(chapter.bookName) \(chapter.chapter)")
.font(.title3.bold())
.padding(.top, 8)
ForEach(chapter.verses) { verse in
Text("\(Text("\(verse.verse)").font(.caption2).foregroundColor(.accentColor).baselineOffset(4)) \(verse.text)")
.font(.body)
.fixedSize(horizontal: false, vertical: true)
}
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private var markButton: some View {
Button {
toggleCompletion()
} label: {
Label(
isCompleted ? "Concluído" : "Marcar como concluído",
systemImage: isCompleted ? "checkmark.circle.fill" : "circle"
)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(isCompleted ? .green : .accentColor)
.controlSize(.large)
.padding()
.background(.bar)
.accessibilityLabel(
isCompleted
? "\(reading.track.displayName) concluída. Toque para desmarcar."
: "Marcar \(reading.track.displayName) como concluída"
)
}
private func toggleCompletion() {
withAnimation {
plan.setCompleted(!isCompleted, day: day, track: reading.track)
}
if plan.isCompleted {
// Plano 180/180 concluído: remove o lembrete diário.
ReadingReminderService.cancelDailyReminder()
}
}
private func load() async {
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
var loaded: [Chapter] = []
for ref in reading.chapters {
loaded.append(try await BibleAPI.chapter(book: ref.bookCode, chapter: ref.chapter))
}
chapters = loaded
} catch {
errorMessage = "Verifique sua conexão e tente novamente."
}
}
}

View File

@@ -2,17 +2,190 @@
// Bible_WeekTests.swift
// Bible-WeekTests
//
// Created by Matheus A Silveira on 04/09/26.
// Testes do Plano de 180 Dias e do lembrete diário (lógica pura,
// sem depender do UNUserNotificationCenter real).
//
import Foundation
import Testing
import UserNotifications
@testable import Bible_Week
struct Bible_WeekTests {
// MARK: - Cronograma do plano
@Test func example() async throws {
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
// Swift Testing Documentation
// https://developer.apple.com/documentation/testing
struct PlanScheduleTests {
@Test func planHas180Days() {
#expect(PlanSchedule.days.count == 180)
#expect(PlanSchedule.days.first?.day == 1)
#expect(PlanSchedule.days.last?.day == 180)
}
@Test func everyDayHasAllThreeTracksWithChapters() {
for day in PlanSchedule.days {
#expect(day.readings.count == PlanTrack.allCases.count)
for reading in day.readings {
#expect(!reading.chapters.isEmpty, "Dia \(day.day), trilha \(reading.track) sem capítulos")
#expect(!reading.label.isEmpty)
}
}
}
/// Cada trilha deve cobrir todos os seus capítulos, em ordem canônica,
/// sem lacunas e sem repetições a Bíblia inteira em 180 dias.
@Test func scheduleCoversEntireCanonExactlyOnce() {
for track in PlanTrack.allCases {
let scheduled = PlanSchedule.days.flatMap { day in
day.reading(for: track)?.chapters ?? []
}
let expected = track.books.flatMap { book in
(1...book.chapterCount).map {
ChapterRef(bookCode: book.code, bookName: book.name, chapter: $0)
}
}
#expect(scheduled == expected, "Trilha \(track) não cobre o cânon exatamente")
}
let total = PlanTrack.allCases.reduce(0) { sum, track in
sum + track.books.reduce(0) { $0 + $1.chapterCount }
}
#expect(total == 1189)
}
@Test func labelsCollapseConsecutiveChaptersIntoRanges() {
let reading = TrackReading(track: .history, chapters: [
ChapterRef(bookCode: "GEN", bookName: "Gênesis", chapter: 34),
ChapterRef(bookCode: "GEN", bookName: "Gênesis", chapter: 35),
ChapterRef(bookCode: "GEN", bookName: "Gênesis", chapter: 36),
])
#expect(reading.label == "Gênesis 3436")
let crossBook = TrackReading(track: .history, chapters: [
ChapterRef(bookCode: "GEN", bookName: "Gênesis", chapter: 50),
ChapterRef(bookCode: "EXO", bookName: "Êxodo", chapter: 1),
])
#expect(crossBook.label == "Gênesis 50 e Êxodo 1")
}
}
// MARK: - Estado do plano
struct ReadingPlanStateTests {
@Test func currentDayAdvancesWhenAllTracksCompleted() {
let plan = ReadingPlanState()
#expect(plan.currentDay == 1)
// Concluir só uma trilha não avança o dia.
plan.setCompleted(true, day: 1, track: .history)
#expect(plan.currentDay == 1)
plan.setCompleted(true, day: 1, track: .wisdom)
plan.setCompleted(true, day: 1, track: .newTestament)
#expect(plan.currentDay == 2)
#expect(plan.completedDaysCount == 1)
}
@Test func settingCompletedTwiceDoesNotDuplicate() {
let plan = ReadingPlanState()
plan.setCompleted(true, day: 1, track: .history)
plan.setCompleted(true, day: 1, track: .history)
#expect(plan.completedKeys.count == 1)
plan.setCompleted(false, day: 1, track: .history)
#expect(plan.completedKeys.isEmpty)
}
@Test func changingReminderTimeReplacesPreviousValue() {
let plan = ReadingPlanState()
#expect(plan.reminderHour == 20)
#expect(plan.reminderMinute == 0)
plan.reminderHour = 7
plan.reminderMinute = 30
#expect(plan.reminderTimeText == "07:30")
}
@Test func pausePreservesReminderPreference() {
let plan = ReadingPlanState()
plan.reminderEnabled = true
plan.reminderHour = 7
plan.reminderMinute = 30
plan.isPaused = true
#expect(plan.reminderEnabled)
#expect(plan.reminderHour == 7)
#expect(plan.reminderMinute == 30)
plan.isPaused = false
#expect(plan.reminderEnabled)
#expect(plan.reminderTimeText == "07:30")
}
@Test func planCompletionDetection() {
let plan = ReadingPlanState()
for day in 1...PlanSchedule.totalDays {
for track in PlanTrack.allCases {
plan.setCompleted(true, day: day, track: track)
}
}
#expect(plan.isCompleted)
#expect(plan.completedDaysCount == 180)
#expect(plan.progress == 1.0)
}
@Test func restartClearsProgressButKeepsReminderPreference() {
let plan = ReadingPlanState()
plan.reminderEnabled = true
plan.reminderHour = 20
plan.reminderMinute = 0
plan.setCompleted(true, day: 1, track: .history)
plan.isPaused = true
plan.restart()
#expect(plan.completedKeys.isEmpty)
#expect(plan.currentDay == 1)
#expect(!plan.isPaused)
// Preferências de lembrete sobrevivem ao reinício.
#expect(plan.reminderEnabled)
#expect(plan.reminderTimeText == "20:00")
}
}
// MARK: - Conteúdo do lembrete
struct ReminderContentTests {
@Test func contentIncludesJourneyDayWhenAvailable() {
let content = ReadingReminderService.makeContent(currentDay: 34, soundEnabled: true)
#expect(content.title.contains("34"))
#expect(!content.body.isEmpty)
#expect(content.sound != nil)
}
@Test func contentFallsBackToDefaultTitleWithoutDay() {
let content = ReadingReminderService.makeContent(currentDay: nil, soundEnabled: true)
#expect(content.title == "📖 Sua leitura de hoje")
}
@Test func soundOffProducesSilentNotification() {
let content = ReadingReminderService.makeContent(currentDay: 1, soundEnabled: false)
#expect(content.sound == nil)
}
@Test func userInfoRoutesToReadingPlanToday() {
let content = ReadingReminderService.makeContent(currentDay: 1, soundEnabled: true)
#expect(content.userInfo[NotificationUserInfo.destinationKey] as? String == NotificationUserInfo.destinationReadingPlan)
#expect(content.userInfo[NotificationUserInfo.planIdKey] as? String == "bible-180")
#expect(content.userInfo[NotificationUserInfo.screenKey] as? String == "today")
}
@Test func messagesAvoidPressuringLanguage() {
for message in ReadingReminderService.motivationalMessages {
#expect(!message.lowercased().contains("streak"))
#expect(!message.lowercased().contains("sequência"))
#expect(!message.lowercased().contains("esqueceu"))
#expect(!message.lowercased().contains("perdendo"))
}
}
}

View File

@@ -10,34 +10,70 @@ import XCTest
final class Bible_WeekUITests: XCTestCase {
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
// In UI tests it is usually best to stop immediately when a failure occurs.
continueAfterFailure = false
// In UI tests its important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
/// Fluxo da aba Bíblia: lista de livros -> Gênesis -> leitura do capítulo 1.
@MainActor
func testExample() throws {
// UI tests must launch the application that they test.
func testNavigateToGenesisChapter1() throws {
let app = XCUIApplication()
app.launch()
// Use XCTAssert and related functions to verify your tests produce the correct results.
// XCUIAutomation Documentation
// https://developer.apple.com/documentation/xcuiautomation
// Espera a lista de livros carregar da API.
let genesis = app.staticTexts["Gênesis"]
XCTAssertTrue(genesis.waitForExistence(timeout: 15), "Lista de livros não carregou")
genesis.tap()
// Espera o texto do capítulo 1 aparecer (Gn 1:1).
let firstVerse = app.staticTexts.containing(
NSPredicate(format: "label CONTAINS[cd] 'princípio'")
).firstMatch
XCTAssertTrue(firstVerse.waitForExistence(timeout: 15), "Capítulo 1 de Gênesis não carregou")
// Pausa para inspeção visual/screenshot externo.
sleep(4)
}
/// Fluxo do plano: começar o Plano de 180 Dias, ativar o lembrete diário
/// (aceitando a permissão do iOS) e verificar o chip e a Leitura de Hoje.
@MainActor
func testLaunchPerformance() throws {
// This measures how long it takes to launch your application.
measure(metrics: [XCTApplicationLaunchMetric()]) {
XCUIApplication().launch()
func testStartPlanAndEnableDailyReminder() throws {
let app = XCUIApplication()
app.launchArguments += ["--reset-plan"]
app.launch()
// Vai para a aba Plano.
let planTab = app.tabBars.buttons["Plano"]
XCTAssertTrue(planTab.waitForExistence(timeout: 10), "Aba Plano não encontrada")
planTab.tap()
// Inicia o plano.
let startButton = app.buttons["Começar plano"]
XCTAssertTrue(startButton.waitForExistence(timeout: 10), "Botão de começar o plano não apareceu")
startButton.tap()
// Prompt contextual do lembrete.
let activateButton = app.buttons["Ativar lembrete"]
XCTAssertTrue(activateButton.waitForExistence(timeout: 5), "Prompt do lembrete não apareceu")
activateButton.tap()
// Alerta de permissão do iOS (só aparece na primeira autorização).
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
let allowButton = springboard.buttons["Allow"].exists
? springboard.buttons["Allow"]
: springboard.buttons["Permitir"]
if allowButton.waitForExistence(timeout: 5) {
allowButton.tap()
}
// De volta ao plano: chip do lembrete e Leitura de Hoje visíveis.
let chip = app.staticTexts["Lembrete às 20:00"]
XCTAssertTrue(chip.waitForExistence(timeout: 10), "Chip do lembrete não apareceu")
XCTAssertTrue(app.staticTexts["Dia 1 de 180"].exists, "Cabeçalho do dia não apareceu")
XCTAssertTrue(app.staticTexts["Novo Testamento"].waitForExistence(timeout: 5), "Trilhas da Leitura de Hoje não apareceram")
// Pausa para inspeção visual/screenshot externo.
sleep(4)
}
}

BIN
logo biblia.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 KiB