Live Activities são uma máquina de estados, não um selo
Gênero: shipped-code. O post documenta a Live Activity que construí no Return, o timer de meditação em SwiftUI que minha esposa usa, minha mãe usa e alguns milhares de estranhos usam.1 Os padrões são aqueles que sobreviveram à produção. O rodapé de honestidade brutal diz o que ainda não sei.
A Live Activity no Return parece um número de contagem regressiva na tela de bloqueio e na Dynamic Island.2 Não é um número. É uma máquina com cinco estados de ciclo de vida, três caminhos externos de dispensa e um caminho de início reentrante que precisa se defender de si mesmo.
Lancei uma v1 que tratava a Live Activity como um selo. O “tempo restante atual” era dado; o resto era decoração. Aquela versão tinha três bugs que peguei no TestFlight e um que peguei em produção:
- Tocar em iniciar enquanto o início já estava em andamento criava uma segunda activity que deixava a primeira órfã.
- A contagem regressiva renderizava corretamente na Dynamic Island, mas a view da tela de bloqueio acionava
endTime <= Date()para timers pausados e mostrava0:00até que o usuário retomasse. - A Live Activity ficava visível muito tempo após o usuário resetar o timer porque a política de dispensa era
.default, que a Apple mantém visível por algum tempo, até quatro horas. - (Produção.) Em locales de idiomas Right-to-Left (árabe, hebraico), os dígitos renderizavam ao contrário na região compact-trailing da Dynamic Island. Dígitos latinos, layout RTL. A correção foi de uma linha.
Cada um deles era um bug de máquina de estados. O número da contagem regressiva estava bem. O número não é o produto. O produto é o estado.
A máquina de estados abaixo é o que sobreviveu àqueles bugs.
TL;DR
- O
LiveActivityManagerem produção expõe 5 métodos de transição (startActivity,updateActivity,showCycleComplete,showFinalCompletion,endActivity) mais 1 leitura (hasActiveActivity). As 224 linhas de produção protegem contra um perigo específico dentro destartActivity: chamadas concorrentes de start mais verificações de cancelamento em cada limite deawaitnesse método.3 - O
ContentStatecarrega 6 campos:endTime,currentCycle,totalCycles,isPaused,isCompleted,remainingSeconds. Os primeiros cinco são os rótulos da máquina de estados. O sexto (remainingSeconds) é um fallback de exibição estática que otimerIntervalao vivo do ActivityKit não consegue servir. - A decisão da política de dispensa é a verdadeira escolha de produto.
.immediatepara reset do usuário,.after(Date().addingTimeInterval(3))para conclusão, nunca o padrão do sistema. - A região compact-trailing da Dynamic Island precisa de
.environment(\.layoutDirection, .leftToRight)no texto do timer para manter os dígitos latinos LTR sob locales de sistema RTL.
A máquina de estados
A Live Activity em produção tem um estado idle, três estados ao vivo que o usuário pode observar, um estado terminal e uma porta reentrante que o desenvolvedor precisa observar:
┌──────────────────────────────────────────────────────────────────┐
│ Lifecycle states │
├──────────────────────────────────────────────────────────────────┤
│ IDLE currentActivity == nil; no Live Activity present │
│ RUNNING isPaused=false, endTime > Date() │
│ PAUSED isPaused=true, remainingSeconds=N │
│ CYCLE_END isPaused=false, endTime <= Date(), isCompleted=false│
│ COMPLETE isCompleted=true (terminal; transitions to IDLE) │
└──────────────────────────────────────────────────────────────────┘
│
↓
┌──────────────────────────────────────────────────────────────────┐
│ Dismissal policies (Apple) │
├──────────────────────────────────────────────────────────────────┤
│ .immediate user reset │
│ .after(now + 3s) completion display window │
│ .default system decides; can stay up to 4 hours │
└──────────────────────────────────────────────────────────────────┘
Reentrancy gate inside startActivity():
isStartingActivity flag + cancellable startActivityTask
prevents two concurrent startActivity() calls from creating
two Live Activities for one timer. Cancellation checks across
each await keep the in-flight task safe to abort.
O caminho de renderização verifica isPaused primeiro; essa ordem é o que impede que um timer pausado renderize como CYCLE_END quando o tempo de relógio cruzou endTime.7
Os nomes dos estados não são rótulos no número. Os nomes dos estados são o contrato entre LiveActivityManager (o lado do app, onde minhas views SwiftUI vivem) e ReturnLiveActivity (a widget extension, onde o processo da Apple renderiza a superfície).
O contrato é TimerActivityAttributes.ContentState, todos os 6 campos:3
public struct ContentState: Codable, Hashable {
var endTime: Date
var currentCycle: Int
var totalCycles: Int?
var isPaused: Bool
var isCompleted: Bool = false
var remainingSeconds: Int = 0
}
Cada transição de estado muta esse struct e pede ao ActivityKit que o entregue através das fronteiras de processo até a widget extension. O widget então re-renderiza. Não há memória compartilhada. Não há callback. Há um struct Codable que cruza uma fronteira de processo em cada transição.
Esse fato exclui qualquer coisa que eu possa querer fazer com closures, view models, observable objects ou propriedades computadas. O estado precisa ser expressável como dados serializáveis. Se não puder ser codificado, não pode transicionar.
O start reentrante
Live Activities têm um limite rígido sobre activities concorrentes e um limite suave sobre o que acontece se você chama Activity.request duas vezes em andamento. O limite rígido está bem documentado.4 O limite suave é “a segunda chamada pode ter sucesso e criar uma órfã.” A órfã é a Live Activity que não está mais associada a currentActivity no seu manager. Ela sobrevive. Não tem caminho de volta para o seu código. Eventualmente ela se dispensa pelo próprio timer de staleness. O usuário vê um timer duplicado até lá.
A órfã foi o bug v1 que o Return lançou. A correção é a porta reentrante mais uma Task cancelável em LiveActivityManager.swift:3
private var isStartingActivity = false
private var startActivityTask: Task<Void, Never>?
func startActivity(...) {
#if os(iOS)
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
guard !isStartingActivity else { return }
isStartingActivity = true
startActivityTask?.cancel()
startActivityTask = Task {
defer {
isStartingActivity = false
startActivityTask = nil
}
guard !Task.isCancelled else { return }
await endActivity() // explicit cleanup of any prior state
guard !Task.isCancelled else { return }
// ... build attributes + contentState ...
do {
let activity = try Activity.request(...)
guard !Task.isCancelled else { return }
currentActivity = activity
} catch {
// log; flag clears via defer
}
}
#endif
}
Três coisas sobre esse padrão que a documentação não menciona:
A flag isStartingActivity é a proteção ativa; startActivityTask?.cancel() é limpeza defensiva. A flag faz curto-circuito em qualquer segunda chamada de startActivity enquanto a primeira está em andamento, então você não chega de fato a competir pelo caminho público. A dança de cancel-then-replace ainda importa porque a Task em andamento é assíncrona e pode sobreviver a um chamador de vida curta; o cancelamento impede que uma Task obsoleta continue depois que o chamador já saiu.
As verificações guard !Task.isCancelled em cada limite de await. O cancelamento é cooperativo em Swift. Mesmo se cancel for chamado, a Task continua rodando até verificar explicitamente. Cada await é uma oportunidade para verificar. Sem as verificações pós-await, uma Task cancelada continua construindo o estado da activity, chama Activity.request e silenciosamente cria uma órfã em caso de sucesso.
O defer limpa a flag antes de o corpo da Task completar. Sem defer, um return antecipado (da verificação de cancelamento) deixa isStartingActivity = true permanentemente e a activity nunca mais inicia até o app ser relançado. A flag é um lock; o lock precisa ser liberado em todo caminho de saída.
O argumento pushType: nil. Return não usa updates de Live Activity enviados via APNs. O app atualiza a activity localmente via activity.update. Se você precisa de updates baseados em push (rastreamento de entrega, placares esportivos, dados em tempo real), o tipo é pushType: .token e o contrato é dramaticamente mais complexo.5 Updates locais são mais simples e cobrem qualquer fluxo de timer / contador / único app.
O problema da pausa
ActivityKit oferece uma view linda Text(timerInterval: Date()...endTime, countsDown: true) que renderiza uma contagem regressiva ao vivo sem qualquer update do app.6 Você define o end time, o sistema renderiza um timer ao vivo. Sem Timer.publish, sem refresh do widget, sem drenar bateria.
Isso é fantástico quando o timer está rodando. Está errado quando o timer está pausado.
O texto timerInterval conta em direção a endTime independentemente de qualquer sinal de “pausa” no estado. Não há modo “congelado em 10:23” no API da Apple. Se você passa endTime = Date().addingTimeInterval(623) e o usuário pausa na marca dos 10:23, o texto do timer continua contando regressivamente até zero no widget. O campo de estado diz pausado. O widget renderiza rodando.
A correção é renderizar duas views diferentes a partir do mesmo estado:7
if context.state.isPaused {
// static text
Text(formatTime(context.state.remainingSeconds))
.monospacedDigit()
} else if context.state.endTime > Date() {
// live countdown
Text(timerInterval: Date()...context.state.endTime, countsDown: true)
.monospacedDigit()
} else {
// post-end static
Text("0:00")
.monospacedDigit()
}
A renderização de duas pistas é a razão pela qual o ContentState carrega remainingSeconds como campo separado. É redundante quando o timer está rodando (o sistema computa a partir de endTime). É a única fonte de verdade quando o timer está pausado. As duas metades do struct servem a dois modos diferentes de renderização; o boolean isPaused seleciona entre eles.
As políticas de dispensa
activity.end(_:dismissalPolicy:) aceita um de três valores ActivityUIDismissalPolicy, e escolher errado foi o que fez minha v1 ficar na tela de bloqueio do usuário pelo que pareceu uma eternidade após um reset:13
| Política | Quando usar | O que você obtém |
|---|---|---|
.immediate |
Reset do usuário, erro, app em background sem activity para rastrear | Activity desaparece agora. Sem janela de tolerância |
.after(date) |
Exibição de conclusão: “sua meditação está completa” precisa ficar legível por um momento. A data deve estar dentro da janela de quatro horas que a Apple permite | Activity mostra o estado final, depois dispensa em date |
.default |
Quando você genuinamente quer que as heurísticas da Apple decidam | O sistema mantém visível “por algum tempo” (palavras da Apple), até quatro horas após end ser chamado |
Return usa .after(Date().addingTimeInterval(3)) para o caminho de conclusão natural:3
await activity.end(
.init(state: contentState, staleDate: nil),
dismissalPolicy: .after(Date().addingTimeInterval(3))
)
Três segundos é o tempo que um usuário precisa para olhar a tela de bloqueio, registrar que o timer terminou e sentir a satisfação do checkmark. Menos de três é abrupto. Mais de três parece que a activity não sabe que terminou.
Para um reset disparado pelo usuário, a chamada é dismissalPolicy: .immediate. Sem janela. O usuário já sabe.
A escolha errada na v1 foi .default. Para um timer de meditação concluído, o sistema mantinha a activity visível tempo o bastante para os usuários acharem que o app não havia registrado a conclusão. A documentação da Apple diz que .default mantém a activity encerrada “visível por algum tempo” até quatro horas;13 a postura correta para um timer é tornar a dispensa explícita.
A região compact da Dynamic Island
A Dynamic Island tem três modos de renderização e você precisa de todos os três mesmo para um timer simples:2
- Compact (forma padrão da Dynamic Island): ícone leading + timer trailing
- Minimal (quando outra Live Activity disputa a mesma Dynamic Island): apenas ícone leading
- Expanded (long-press): quatro regiões nomeadas (
leading,trailing,center,bottom)
O padrão que conquistou seu lugar no Return é tornar a view expandida quase idêntica à compact:8
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Image("AppIconSmall")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 16, height: 16)
.clipShape(RoundedRectangle(cornerRadius: 4))
}
DynamicIslandExpandedRegion(.trailing) {
TimerText(...)
}
DynamicIslandExpandedRegion(.center) { EmptyView() }
DynamicIslandExpandedRegion(.bottom) { EmptyView() }
} compactLeading: {
Image("AppIconSmall")...
} compactTrailing: {
TimerText(...)
} minimal: {
Image("AppIconSmall")...
}
A maioria dos tutoriais de Live Activity se apoia na view expandida como o design “real”, com conteúdo rico na região bottom. Para um timer de meditação, expansão é peso morto. O usuário abre a view expandida com long-press, e o long-press já dá o feedback háptico de que algo aconteceu. Adicionar conteúdo faz a expansão dizer algo que o usuário não pediu. Regiões vazias no modo expandido não são uma falha do design; elas são o design.
O bug RTL
O bug de produção. Usuários árabes e hebraicos no iOS reportaram que o timer compact-trailing da Dynamic Island renderizava os dígitos ao contrário. A string de numeral latino 5:23 estava renderizando como 32:5 porque a direção de layout do compact-trailing herdava a configuração RTL do locale do sistema.
SwiftUI herda a direção de layout do sistema dentro do processo do widget, então o texto do timer da Dynamic Island pegava RTL quando o telefone do usuário estava configurado em árabe ou hebraico. Numerais latinos deveriam renderizar LTR mesmo dentro de uma UI que de outra forma é RTL. A correção é fixar a direção de layout nas views de texto numérico:7
.environment(\.layoutDirection, .leftToRight)
A sobrescrita vai nas views Text numéricas dentro de TimerText (Dynamic Island compact / expanded) e dentro da view da tela de bloqueio, não na view inteira. Dígitos latinos leem da esquerda para a direita independentemente do locale de sistema do usuário; rótulos de ciclo como “Cycle 2 of 3” permanecem localizados, então seguem a direção de layout do sistema.
O bug não aparece em TestFlight de locale doméstico. Ele aparece no momento em que um usuário RTL real abre o timer. A lição: envie a sobrescrita de environment fixada em LTR em toda view de texto com dígitos latinos em qualquer Live Activity que possa rodar em locales RTL.
A história da localização
TimerActivityAttributes carrega um campo languageCode: String definido pelo app na criação da activity:9
let attributes = TimerActivityAttributes(
timerDuration: duration,
languageCode: settings.appLanguage // app's selected language, not system's
)
A widget extension lê isso para renderizar strings localizadas:
private var locale: Locale {
let code = context.attributes.languageCode
return code.isEmpty ? .current : Locale(identifier: code)
}
private func localized(_ key: String.LocalizationValue) -> String {
String(localized: key, locale: locale)
}
Por que o app passa seu próprio código de idioma em vez de deixar o widget ler Locale.current: a widget extension roda em seu próprio processo. Seu Locale.current é o locale do sistema, não o locale selecionado do app. Se um usuário definiu o Return em coreano enquanto o iPhone está em inglês, o widget falaria em inglês sem essa sobrescrita. A preferência de idioma do app viaja nos atributos da activity; o widget a respeita.
Localizable.xcstrings vive no target do widget junto ao do app, mas são arquivos separados. Strings usadas no widget precisam existir em ReturnWidgets/Localizable.xcstrings mesmo que a mesma string exista em Return/Localizable.xcstrings. Esquecer disso significa que o widget volta para o idioma de desenvolvimento enquanto o app fala em coreano.
O que eu construiria diferente
Tornar o ContentState menor. Seis campos é demais. A redundância entre endTime e remainingSeconds é o preço de contornar a falta de modo de pausa em timerInterval. Se eu estivesse começando do zero, eu carregaria um único enum displayMode (running, paused(remainingSeconds: Int), cycleEnd, complete) e deixaria o código de renderização despachar pelo case. Seis campos é mais difícil de manter corretamente mutados em cinco métodos de transição do que quatro cases.
Adicionar botões interativos de Live Activity (iOS 17+). Atualmente o Return não expõe controles de pausar/retomar na Dynamic Island. O usuário precisa abrir o app para pausar. iOS 17 adicionou Button(intent:) para App Intents dentro de Live Activities.10 Um controle interativo de pausa é a extensão óbvia e a próxima coisa que vou lançar para o Return.
Live Activities com push update para sincronização de timer entre dispositivos. Return sincroniza sessões entre iPhone, iPad, Watch e Apple TV via NSUbiquitousKeyValueStore (coberto em Cinco plataformas Apple, três arquivos compartilhados). Hoje a activity é iniciada localmente a partir do app no iPhone ou iPad e atualizada localmente. Um usuário iniciando um timer no Apple Watch poderia idealmente ver a Live Activity refletir isso no iPhone em tempo real. O push APNs para a Live Activity é o caminho.5 Não construí isso.
Quando não usar Live Activities
Estado transiente de uma única ocorrência. Um toast “salvo!” não merece uma Live Activity. O sistema tem um banner. Use-o.
Dados que mudam frequentemente sem dimensão de timer. Live Activities funcionam melhor para coisas com uma âncora temporal clara (um timer, um ETA de entrega, um relógio de jogo, a duração de uma chamada telefônica). Cotações de ações e placares esportivos funcionam porque têm uma janela de sessão. Um dashboard de propósito geral não.
Apps sem caso de uso de tela de bloqueio / standby. Live Activities exigem investimento real de engenharia (configuração do target, design do ContentState, decisões de política de dispensa, tratamento de RTL, encanamento de localização). Apps que o usuário abre diretamente sem nunca consultar a tela de bloqueio durante o uso não têm o formato certo. Um editor de fotos não precisa de uma. Um rastreador de treinos sim.
Em superfícies não iOS, com ressalvas. O LiveActivityManager do Return distribui sua implementação atrás de #if os(iOS) porque o timer é iniciado pelo app no iPhone ou iPad. O ActivityKit em si descreve o banner da tela de bloqueio, Dynamic Island, Apple Watch Smart Stack, Mac e CarPlay como superfícies de apresentação; iOS 26 expandiu várias delas.4 watchOS ainda tem suas próprias complications API para renderização em tela cheia. macOS tem apps de menu bar. iPadOS suporta Live Activities desde iPadOS 17 sem região de Dynamic Island. O manager do Return tem 8 guards #if os(iOS) em um arquivo único de 224 linhas.
O que o padrão significa para apps lançando em iOS 26+
Duas conclusões.
-
Trate a Live Activity como uma máquina de estados, não um número. A máquina de estados tem estados claros, transições claras e regras claras de dispensa. O número na tela é uma renderização de um estado. Acerte os estados primeiro.
-
A guarda de reentrância é o bug que você ainda não pegou. Todo Live Activity manager que vi por aí que não implementa
isStartingActivity+ Task cancelável já lançou pelo menos um bug de activity órfã. A guarda tem 6 linhas. Escreva uma vez.
Combine este post com meus writeups anteriores para a mesma família de apps: App Intents tipados para a Apple Intelligence; servidores MCP para agentes cross-LLM; padrões de Liquid Glass para a camada visual; shipping multiplataforma para alcance entre dispositivos. Live Activities são a camada da tela de bloqueio do iOS e da Dynamic Island do mesmo stack. O conjunto completo vive no hub Apple Ecosystem Series. Para o contexto mais amplo de iOS com agentes de IA, veja o guia iOS Agent Development.
FAQ
Qual a diferença entre Live Activities e widgets do WidgetKit?
Widgets do WidgetKit renderizam em intervalos definidos por TimelineProvider; o sistema decide quando atualizar e o widget re-renderiza a partir de uma timeline estática.11 Live Activities renderizam em resposta a chamadas específicas activity.update(...) direcionadas pelo app e vivem pela duração da activity subjacente (um timer, uma entrega, um treino). Ambos são distribuídos no target da widget extension; a diferença é o modelo de gatilho.
Live Activities funcionam no iPad?
Sim, no iPadOS 17+. O banner da tela de bloqueio é a principal superfície de renderização; o iPad não tem Dynamic Island. O mesmo código ActivityConfiguration funciona; apenas espere que as regiões da Dynamic Island nunca renderizem no iPad.
Uma Live Activity pode sobreviver ao processo do meu app?
Sim. Uma vez que Activity.request tenha sucesso, o ActivityKit assume a activity. O processo do app pode ser terminado pelo sistema; a activity continua renderizando na tela de bloqueio e na Dynamic Island até você encerrá-la explicitamente (ou até que as regras de staleness do sistema a dispensem). Chamadas explícitas de endActivity() importam por essa razão; sem um end explícito no reset do app, a activity sobrevive ao timer.
Por que o post não cobre Live Activities atualizadas via push?
Eu não lancei Live Activities atualizadas via push no Return. Pela regra de gênero deste cluster: posts shipped-code só documentam o que o código de produção faz. Atualizações por push estão listadas em “O que eu construiria diferente”; um post futuro vai cobri-las depois que eu lançá-las.
Qual é o layout real de arquivos para Live Activities num app SwiftUI?
- No target principal do app:
LiveActivityManager.swift(gerencia o ciclo de vida da activity),TimerActivityAttributes.swift(o structActivityAttributescompartilhado com o widget; ambos os targets compilam esse arquivo). - No target da widget extension:
ReturnLiveActivity.swift(a conformidade comWidgetcom corpo deActivityConfiguration),ReturnWidgetsBundle.swift(o@main WidgetBundle). - Configuração:
Info.plistcomNSSupportsLiveActivities = YESno target do app.
O target da widget extension precisa dos imports de ActivityKit e WidgetKit. TimerActivityAttributes é o único arquivo compartilhado entre os dois targets; todo o resto é isolado por target.
A Live Activity não é um número na tela de bloqueio. É uma máquina de estados que cruza uma fronteira de processo a cada transição. Acerte os estados, proteja a reentrância, escolha a política de dispensa de propósito e fixe a direção de layout. O número se cuida sozinho.
Referências
-
Return do autor, um timer de meditação em SwiftUI publicado na App Store em 21 de abril de 2026, disponível para iPhone, iPad, Mac, Apple Watch e Apple TV. Live Activities são distribuídas apenas no target iOS. ↩
-
Apple Developer, “ActivityKit framework”. Banner da tela de bloqueio, modos compact / minimal / expanded da Dynamic Island, ciclo de vida da activity. Disponível iOS 16.1+; Dynamic Island disponível no iPhone 14 Pro e posteriores. ↩↩
-
Código de produção em
Return/Return/LiveActivityManager.swift(224 linhas, 8 blocos#if os(iOS)) eReturn/Return/TimerActivityAttributes.swift(43 linhas). Compartilhado entre o target do app e o target da widget extension via target membership. ↩↩↩↩↩ -
Apple Developer, “Displaying live data with Live Activities”. Limites de concorrência, plataformas suportadas (iOS 16.1+, iPadOS 17+), chave
NSSupportsLiveActivitiesdo Info.plist. ↩↩ -
Apple Developer, “Updating and ending your Live Activity with ActivityKit push notifications”. O caminho
pushType: .tokenrequer uma chave de auth APNs separada, registro de push token no servidor e um protocolo de update diferente das chamadas locaisactivity.update(...). ↩↩ -
Apple Developer, “Text(timerInterval:pauseTime:countsDown:showsHours:)”. Timer de contagem regressiva renderizado pelo sistema ao vivo; renderiza sem updates do app enquanto a activity está rodando. ↩
-
Código de produção em
Return/ReturnWidgets/ReturnLiveActivity.swift(232 linhas). A conformidadeWidgetda widget extension com corpoActivityConfiguration<TimerActivityAttributes>. A viewTimerTextnas linhas 61-102 lida com a renderização de três estados: paused / running / post-end. ↩↩↩↩ -
Apple Developer, “DynamicIsland”. As quatro regiões expandidas nomeadas (
leading,trailing,center,bottom) mais três views do modo compact (compactLeading,compactTrailing,minimal). ↩ -
A widget extension roda em seu próprio processo e herda o locale do sistema, não o locale selecionado do app. Apps que suportam troca de idioma in-app (Return suporta 27 idiomas) precisam passar o código do idioma através de
ActivityAttributespara que o widget possa renderizar no idioma escolhido pelo usuário. Padrão:Locale(identifier: context.attributes.languageCode)em vez deLocale.current. ↩ -
Apple Developer, “Button(intent:)”. Disponível em views de widget e Live Activity desde iOS 17+. Faz a ponte de App Intents para controles na tela de bloqueio / Dynamic Island sem requerer o app em foreground. ↩
-
Apple Developer, “TimelineProvider”. O modelo de refresh de widget que antecede as Live Activities; entradas pré-computadas com janelas de reload gerenciadas pelo sistema. ↩
-
Código de produção em
Return/ReturnWidgets/ReturnWidgetsBundle.swift(16 linhas). O@main WidgetBundleque registraReturnLiveActivitycomo o único widget da widget extension. Padrão obrigatório para widget extensions; o bundle é o que o sistema carrega. ↩ -
Apple Developer, “ActivityUIDismissalPolicy”. Três casos:
.default,.immediate,.after(_:). A Apple afirma que.defaultmantém uma Live Activity encerrada visível “por algum tempo” até quatro horas, e.after(_:)aceita uma data dentro da mesma janela de quatro horas. ↩↩