Files
2026-09-17 17:59:34 +02:00

125 lines
6.9 KiB
Swift

import FamilyControls
import SwiftUI
@main
struct FamilyQuestsApp: App {
@StateObject private var model = AppModel()
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
RootView(model: model)
.task { await model.synchronize() }
.onChange(of: scenePhase) { _, phase in
if phase == .active { Task { await model.synchronize() } }
if phase == .background { model.closeParentSetup() }
}
}
}
}
struct RootView: View {
@ObservedObject var model: AppModel
@State private var server = ""
@State private var code = ""
@State private var configurationCode = ""
@State private var replacementCode = ""
var body: some View {
NavigationStack {
Form {
if model.credentials == nil {
Section("Parent: pair this iPhone") {
Text("Create a child and a pairing code in the parent web dashboard. A guardian must approve Apple Family Controls.")
TextField("https://your-server.example", text: $server)
.textInputAutocapitalization(.never).autocorrectionDisabled().keyboardType(.URL)
SecureField("Single-use pairing code", text: $code)
.textInputAutocapitalization(.never).autocorrectionDisabled()
Button("Pair and request guardian approval") {
let submitted = code; code = ""
Task { await model.pair(server: server, code: submitted) }
}
}
} else {
Section(model.snapshot?.childName ?? "My time") {
if let snapshot = model.snapshot {
LabeledContent("Daily allowance", value: "\(snapshot.policy.dailyMinutes) minutes")
LabeledContent("Bonus earned today", value: "\(snapshot.bonus(at: Date())) minutes")
Text("This is your allowance, not a live remaining-time counter. Rewards expire at the end of the family day and never extend bedtime.")
.font(.footnote).foregroundStyle(.secondary)
}
Text(model.status?.detail ?? "Open or sync the app to check configuration")
Button("Sync policy and rewards") { Task { await model.synchronize() } }
}
Section("Maths challenge") {
if let challenge = model.challenge {
ForEach(challenge.questions) { question in
HStack {
Text("\(question.prompt) =")
TextField("Answer", text: Binding(
get: { model.answers[question.id, default: ""] },
set: { model.answers[question.id] = $0 }
)).keyboardType(.numberPad).multilineTextAlignment(.trailing)
.accessibilityLabel("Answer to \(question.prompt)")
}
}
Button("Check answers") { Task { await model.submitChallenge() } }
} else {
Text("Answer every question correctly to earn bonus app usage. An internet connection is required to verify answers.")
Button("Start challenge") { Task { await model.startChallenge() } }
.disabled(model.status?.state != "monitoring")
}
if !model.rewardMessage.isEmpty { Text(model.rewardMessage) }
}
Section("Parent setup") {
Text("App selection requires a fresh configuration code from the parent dashboard. Do not restrict this app, Phone, or other essential communication and school apps.")
.font(.footnote)
Button("Request guardian Screen Time authorization") { Task { await model.authorize() } }
SecureField("Configuration code", text: $configurationCode)
.textInputAutocapitalization(.never).autocorrectionDisabled()
Button("Authorize app selection") {
let value = configurationCode; configurationCode = ""
Task { await model.unlockConfiguration(code: value) }
}
DisclosureGroup("Replace revoked pairing") {
Text("The parent must revoke the old pairing and issue a new pairing code. The server address remains fixed.")
SecureField("New pairing code", text: $replacementCode)
.textInputAutocapitalization(.never).autocorrectionDisabled()
Button("Pair again with the same server") {
let value = replacementCode; replacementCode = ""
Task { await model.pair(server: "", code: value) }
}
}
}
}
if !model.message.isEmpty {
Section("Needs attention") { Text(model.message).foregroundStyle(.red) }
}
if model.busy { ProgressView("Working…") }
Section { Text("Development MVP. DeviceActivity behavior must be tested on this iOS version before relying on it.").font(.footnote) }
}
.disabled(model.busy)
.navigationTitle("Family Quests")
.sheet(isPresented: $model.pickerPresented, onDismiss: { model.closeParentSetup() }) {
NavigationStack {
VStack {
Text("Choose individual entertainment apps only. Leave Family Quests and essential apps accessible.")
.font(.footnote).padding()
FamilyActivityPicker(selection: $model.draftSelection)
}
.navigationTitle("Parent app selection")
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { model.closeParentSetup() } }
ToolbarItem(placement: .confirmationAction) {
Button("Save") { Task { await model.saveSelection() } }.disabled(model.busy)
}
}
.safeAreaInset(edge: .bottom) {
if !model.message.isEmpty { Text(model.message).font(.footnote).padding() }
}
}
}
}
}
}