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

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

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

View File

@@ -0,0 +1,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
)
}
}