diff --git a/Bible-Week/AppRouter.swift b/Bible-Week/AppRouter.swift new file mode 100644 index 0000000..9b70499 --- /dev/null +++ b/Bible-Week/AppRouter.swift @@ -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] + } +} diff --git a/Bible-Week/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/Bible-Week/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..6ecf264 Binary files /dev/null and b/Bible-Week/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/Bible-Week/Assets.xcassets/AppIcon.appiconset/Contents.json b/Bible-Week/Assets.xcassets/AppIcon.appiconset/Contents.json index ffdfe15..5081081 100644 --- a/Bible-Week/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Bible-Week/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "AppIcon.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/Bible-Week/BibleAPI.swift b/Bible-Week/BibleAPI.swift new file mode 100644 index 0000000..f9abbbf --- /dev/null +++ b/Bible-Week/BibleAPI.swift @@ -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(_ 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)") + } +} diff --git a/Bible-Week/BibleCanon.swift b/Bible-Week/BibleCanon.swift new file mode 100644 index 0000000..5196327 --- /dev/null +++ b/Bible-Week/BibleCanon.swift @@ -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: "Jó", 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) -> [Book] { + books.filter { range.contains($0.bookOrder) } + } +} diff --git a/Bible-Week/Bible_WeekApp.swift b/Bible-Week/Bible_WeekApp.swift index bda8ebc..60e74bc 100644 --- a/Bible-Week/Bible_WeekApp.swift +++ b/Bible-Week/Bible_WeekApp.swift @@ -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) } } diff --git a/Bible-Week/BooksView.swift b/Bible-Week/BooksView.swift new file mode 100644 index 0000000..8866822 --- /dev/null +++ b/Bible-Week/BooksView.swift @@ -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() +} diff --git a/Bible-Week/ChapterReaderView.swift b/Bible-Week/ChapterReaderView.swift new file mode 100644 index 0000000..dfbafa8 --- /dev/null +++ b/Bible-Week/ChapterReaderView.swift @@ -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." + } + } +} diff --git a/Bible-Week/ContentView.swift b/Bible-Week/ContentView.swift index 2e2a68e..331e2b6 100644 --- a/Bible-Week/ContentView.swift +++ b/Bible-Week/ContentView.swift @@ -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: 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) } diff --git a/Bible-Week/Item.swift b/Bible-Week/Item.swift deleted file mode 100644 index ec2b0d0..0000000 --- a/Bible-Week/Item.swift +++ /dev/null @@ -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 - } -} diff --git a/Bible-Week/PlanSettingsView.swift b/Bible-Week/PlanSettingsView.swift new file mode 100644 index 0000000..1014c91 --- /dev/null +++ b/Bible-Week/PlanSettingsView.swift @@ -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 { + 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 { + 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 { + 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 + } + } + } +} diff --git a/Bible-Week/ReadingPlanSchedule.swift b/Bible-Week/ReadingPlanSchedule.swift new file mode 100644 index 0000000..0ba6f46 --- /dev/null +++ b/Bible-Week/ReadingPlanSchedule.swift @@ -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 34–36" ou "Obadias 1 e Jonas 1–2". + 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.. 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.. 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 + } +} diff --git a/Bible-Week/ReadingPlanView.swift b/Bible-Week/ReadingPlanView.swift new file mode 100644 index 0000000..bc627b7 --- /dev/null +++ b/Bible-Week/ReadingPlanView.swift @@ -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) +} diff --git a/Bible-Week/ReadingReminderService.swift b/Bible-Week/ReadingReminderService.swift new file mode 100644 index 0000000..0271d34 --- /dev/null +++ b/Bible-Week/ReadingReminderService.swift @@ -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 + ) + } +} diff --git a/Bible-Week/TrackReaderView.swift b/Bible-Week/TrackReaderView.swift new file mode 100644 index 0000000..cd5d9a1 --- /dev/null +++ b/Bible-Week/TrackReaderView.swift @@ -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." + } + } +} diff --git a/Bible-WeekTests/Bible_WeekTests.swift b/Bible-WeekTests/Bible_WeekTests.swift index 93d605f..ad75f1e 100644 --- a/Bible-WeekTests/Bible_WeekTests.swift +++ b/Bible-WeekTests/Bible_WeekTests.swift @@ -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 34–36") + + 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")) + } + } } diff --git a/Bible-WeekUITests/Bible_WeekUITests.swift b/Bible-WeekUITests/Bible_WeekUITests.swift index 849a526..fec0021 100644 --- a/Bible-WeekUITests/Bible_WeekUITests.swift +++ b/Bible-WeekUITests/Bible_WeekUITests.swift @@ -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 it’s 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) } } diff --git a/logo biblia.png b/logo biblia.png new file mode 100644 index 0000000..6d27fa3 Binary files /dev/null and b/logo biblia.png differ