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:
59
Bible-Week/AppRouter.swift
Normal file
59
Bible-Week/AppRouter.swift
Normal 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]
|
||||
}
|
||||
}
|
||||
BIN
Bible-Week/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
BIN
Bible-Week/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 763 KiB |
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
|
||||
89
Bible-Week/BibleAPI.swift
Normal file
89
Bible-Week/BibleAPI.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
86
Bible-Week/BibleCanon.swift
Normal file
86
Bible-Week/BibleCanon.swift
Normal 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: "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<Int>) -> [Book] {
|
||||
books.filter { range.contains($0.bookOrder) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
93
Bible-Week/BooksView.swift
Normal file
93
Bible-Week/BooksView.swift
Normal 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()
|
||||
}
|
||||
96
Bible-Week/ChapterReaderView.swift
Normal file
96
Bible-Week/ChapterReaderView.swift
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
@Bindable var router = router
|
||||
TabView(selection: $router.selectedTab) {
|
||||
Tab("Bíblia", systemImage: "book", value: AppRouter.Tab.bible) {
|
||||
BooksView()
|
||||
}
|
||||
Tab("Plano", systemImage: "calendar.badge.checkmark", value: AppRouter.Tab.plan) {
|
||||
ReadingPlanView()
|
||||
}
|
||||
}
|
||||
.onDelete(perform: deleteItems)
|
||||
}
|
||||
#if os(macOS)
|
||||
.navigationSplitViewColumnWidth(min: 180, ideal: 200)
|
||||
#endif
|
||||
.toolbar {
|
||||
#if os(iOS)
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
EditButton()
|
||||
.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)
|
||||
}
|
||||
#endif
|
||||
ToolbarItem {
|
||||
Button(action: addItem) {
|
||||
Label("Add Item", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
150
Bible-Week/PlanSettingsView.swift
Normal file
150
Bible-Week/PlanSettingsView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
114
Bible-Week/ReadingPlanSchedule.swift
Normal file
114
Bible-Week/ReadingPlanSchedule.swift
Normal 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 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..<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)
|
||||
}
|
||||
}
|
||||
}
|
||||
94
Bible-Week/ReadingPlanState.swift
Normal file
94
Bible-Week/ReadingPlanState.swift
Normal 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
|
||||
}
|
||||
}
|
||||
313
Bible-Week/ReadingPlanView.swift
Normal file
313
Bible-Week/ReadingPlanView.swift
Normal 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)
|
||||
}
|
||||
111
Bible-Week/ReadingReminderService.swift
Normal file
111
Bible-Week/ReadingReminderService.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
113
Bible-Week/TrackReaderView.swift
Normal file
113
Bible-Week/TrackReaderView.swift
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
BIN
logo biblia.png
Normal file
BIN
logo biblia.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 836 KiB |
Reference in New Issue
Block a user