- 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>
323 lines
12 KiB
Swift
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)
|
|
}
|