- 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>
112 lines
4.3 KiB
Swift
112 lines
4.3 KiB
Swift
//
|
|
// 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
|
|
)
|
|
}
|
|
}
|