Files
bible/Bible-Week/ReadingPlanView.swift
Matheus A Silveira 017f4c022a Corrige toggle do lembrete diário que não ligava
- Re-verifica a permissão de notificações quando o app volta ao primeiro
  plano (scenePhase), refletindo mudanças feitas nos Ajustes do iPhone
- Toggle otimista: liga imediatamente ao toque e reverte apenas se a
  autorização for negada
- Prompt inicial completa a ativação sozinho ao voltar dos Ajustes com
  a permissão concedida
- Novo teste de UI: ativar o lembrete pelo toggle das configurações

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-04 19:16:15 -03:00

323 lines
12 KiB
Swift

//
// 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
@Environment(\.scenePhase) private var scenePhase
@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)
// Se o usuário foi aos Ajustes e habilitou as notificações,
// completa a ativação automaticamente ao voltar para o app.
.task(id: scenePhase) {
guard scenePhase == .active, showDeniedInfo else { return }
if await ReadingReminderService.authorizationStatus() == .authorized {
activateReminder()
}
}
}
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)
}