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:
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
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user