feat: clone Opal's rules feature with hard block enforcement
Rebuild the app around recurring screen-time blocking rules modeled on Opal's My Apps tab: - Onboarding with FamilyControls Screen Time authorization - Apps home with Blocked Apps tiles and live rule cards - New Rule sheet: Schedule/Time Limit/Open Limit types + preset gallery - Rule editors: time windows (incl. overnight), day picker, app selection via FamilyActivityPicker (Block/Allow Only), rename, Hold to Commit - Hard Mode: active hard rules cannot be edited, disabled, deleted, or unblocked until their window ends; soft rules pause until next window - Shield enforcement through per-rule ManagedSettingsStore + RuleEnforcer - 73 unit tests (Swift Testing) + 16 UI tests (XCUITest) with launch-arg harness for in-memory storage, mocked authorization, seeded scenarios - docs/RULES_FEATURE_SPEC.md: spec derived from the reference recording Known gap: background window transitions and time/open-limit thresholds need a DeviceActivityMonitor extension; shields currently sync while the app is running.
This commit is contained in:
203
Severed/Views/Apps/AppsHomeView.swift
Normal file
203
Severed/Views/Apps/AppsHomeView.swift
Normal file
@@ -0,0 +1,203 @@
|
||||
//
|
||||
// AppsHomeView.swift
|
||||
// Severed
|
||||
//
|
||||
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
/// The app's home screen, modeled on the reference "Apps" tab: what's blocked
|
||||
/// right now, then the Rules carousel with "+ New".
|
||||
struct AppsHomeView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(RuleEnforcer.self) private var enforcer
|
||||
@Query(sort: \BlockingRule.createdAt) private var rules: [BlockingRule]
|
||||
|
||||
@State private var detailRule: BlockingRule?
|
||||
@State private var showingNewRule = false
|
||||
@State private var unblockCandidate: BlockingRule?
|
||||
@State private var hardModeBlockedAttempt = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
TimelineView(.periodic(from: .now, by: 30)) { timeline in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 28) {
|
||||
blockedAppsSection(now: timeline.date)
|
||||
rulesSection(now: timeline.date)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.background(Theme.background)
|
||||
.navigationTitle("Apps")
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
}
|
||||
.sheet(item: $detailRule) { rule in
|
||||
RuleDetailSheet(rule: rule)
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.sheet(isPresented: $showingNewRule) {
|
||||
NewRuleSheet()
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Unblock \(unblockCandidate?.name ?? "")?",
|
||||
isPresented: Binding(
|
||||
get: { unblockCandidate != nil },
|
||||
set: { if !$0 { unblockCandidate = nil } }
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Unblock", role: .destructive) {
|
||||
if let rule = unblockCandidate {
|
||||
RulePolicy.unblock(rule)
|
||||
refreshEnforcement()
|
||||
}
|
||||
unblockCandidate = nil
|
||||
}
|
||||
} message: {
|
||||
Text("Blocking resumes with the rule's next window.")
|
||||
}
|
||||
.alert("Hard Mode is on", isPresented: $hardModeBlockedAttempt) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text("This block can't be lifted until it ends.")
|
||||
}
|
||||
.task {
|
||||
await enforcementLoop()
|
||||
}
|
||||
.onChange(of: ruleChangeToken) {
|
||||
refreshEnforcement()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Blocked Apps
|
||||
|
||||
@ViewBuilder
|
||||
private func blockedAppsSection(now: Date) -> some View {
|
||||
let blocking = rules.filter { $0.status(at: now).isActive }
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Blocked Apps")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
if blocking.isEmpty {
|
||||
Text("Nothing is blocked right now.")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
.accessibilityIdentifier("nothingBlockedLabel")
|
||||
} else {
|
||||
HStack(spacing: 16) {
|
||||
ForEach(blocking) { rule in
|
||||
blockedTile(for: rule, now: now)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func blockedTile(for rule: BlockingRule, now: Date) -> some View {
|
||||
Button {
|
||||
if RulePolicy.canUnblock(rule, at: now) {
|
||||
unblockCandidate = rule
|
||||
} else {
|
||||
hardModeBlockedAttempt = true
|
||||
}
|
||||
} label: {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: rule.hardMode ? "lock.fill" : "lock.open.fill")
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
.frame(width: 58, height: 58)
|
||||
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 14))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.strokeBorder(Theme.accent.opacity(0.6), lineWidth: 1)
|
||||
)
|
||||
Text("Unblock")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("blockedTile-\(rule.name)")
|
||||
}
|
||||
|
||||
// MARK: - Rules
|
||||
|
||||
private func rulesSection(now: Date) -> some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack {
|
||||
Text("Rules")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
Spacer()
|
||||
Button {
|
||||
showingNewRule = true
|
||||
} label: {
|
||||
Label("New", systemImage: "plus")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Theme.accent)
|
||||
}
|
||||
.accessibilityIdentifier("newRuleButton")
|
||||
}
|
||||
if rules.isEmpty {
|
||||
emptyRulesCard
|
||||
} else {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 14) {
|
||||
ForEach(rules) { rule in
|
||||
Button {
|
||||
detailRule = rule
|
||||
} label: {
|
||||
RuleCardView(rule: rule, now: now)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("ruleCard-\(rule.name)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyRulesCard: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("No rules yet")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
Text("Create a rule to block distracting apps on a schedule.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(18)
|
||||
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 22))
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityIdentifier("emptyRulesCard")
|
||||
}
|
||||
|
||||
// MARK: - Enforcement
|
||||
|
||||
/// Changes whenever any rule's blocking-relevant state changes.
|
||||
private var ruleChangeToken: String {
|
||||
rules.map {
|
||||
"\($0.id)|\($0.isEnabled)|\($0.hardMode)|\($0.startMinutes)|\($0.endMinutes)|"
|
||||
+ "\($0.dayNumbers)|\($0.pausedUntil?.timeIntervalSince1970 ?? 0)"
|
||||
}
|
||||
.joined(separator: ",")
|
||||
}
|
||||
|
||||
private func refreshEnforcement() {
|
||||
enforcer.refresh(rules: rules)
|
||||
}
|
||||
|
||||
/// Keeps shields in sync while the app is open, so windows that begin or
|
||||
/// end while the user is looking at the screen take effect promptly.
|
||||
private func enforcementLoop() async {
|
||||
while !Task.isCancelled {
|
||||
let allRules = (try? modelContext.fetch(FetchDescriptor<BlockingRule>())) ?? []
|
||||
enforcer.refresh(rules: allRules)
|
||||
try? await Task.sleep(for: .seconds(30))
|
||||
}
|
||||
}
|
||||
}
|
||||
90
Severed/Views/Apps/RuleCardView.swift
Normal file
90
Severed/Views/Apps/RuleCardView.swift
Normal file
@@ -0,0 +1,90 @@
|
||||
//
|
||||
// RuleCardView.swift
|
||||
// Severed
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// A rule tile in the home screen's Rules carousel: icon pair, live status,
|
||||
/// name, and the block summary. Active rules glow green.
|
||||
struct RuleCardView: View {
|
||||
let rule: BlockingRule
|
||||
let now: Date
|
||||
|
||||
private var status: RuleStatus { rule.status(at: now) }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
RuleIconPair(kind: rule.kind, isActive: status.isActive)
|
||||
statusView
|
||||
Text(rule.name)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 5) {
|
||||
Text(rule.selectionMode.displayName)
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
Text(blockSummary)
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
}
|
||||
.font(.system(size: 13))
|
||||
}
|
||||
.padding(16)
|
||||
.frame(width: 172, alignment: .leading)
|
||||
.background(cardBackground, in: RoundedRectangle(cornerRadius: 22))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 22)
|
||||
.strokeBorder(
|
||||
status.isActive ? Theme.accent.opacity(0.35) : .white.opacity(0.08),
|
||||
lineWidth: 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusView: some View {
|
||||
switch status {
|
||||
case .active:
|
||||
Text(status.label(relativeTo: now))
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.black)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(Theme.accent, in: Capsule())
|
||||
.accessibilityIdentifier("ruleStatus-\(rule.name)")
|
||||
case .paused, .disabled, .dormant, .upcoming:
|
||||
Text(statusText)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
.padding(.vertical, 5)
|
||||
.accessibilityIdentifier("ruleStatus-\(rule.name)")
|
||||
}
|
||||
}
|
||||
|
||||
private var statusText: String {
|
||||
switch rule.kind {
|
||||
case .schedule:
|
||||
status.label(relativeTo: now)
|
||||
case .timeLimit:
|
||||
rule.isEnabled ? "\(rule.dailyLimitMinutes)m / day" : "Disabled"
|
||||
case .openLimit:
|
||||
rule.isEnabled ? "\(rule.maxOpens) opens / day" : "Disabled"
|
||||
}
|
||||
}
|
||||
|
||||
private var blockSummary: String {
|
||||
rule.selectionCount == 1 ? "· 1 app" : "· \(rule.selectionCount) apps"
|
||||
}
|
||||
|
||||
private var cardBackground: some ShapeStyle {
|
||||
if status.isActive {
|
||||
return AnyShapeStyle(
|
||||
LinearGradient(
|
||||
colors: [Theme.activeCardTop, Theme.activeCardBottom],
|
||||
startPoint: .top, endPoint: .bottom
|
||||
)
|
||||
)
|
||||
}
|
||||
return AnyShapeStyle(Theme.surface)
|
||||
}
|
||||
}
|
||||
61
Severed/Views/Components/DayOfWeekPicker.swift
Normal file
61
Severed/Views/Components/DayOfWeekPicker.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// DayOfWeekPicker.swift
|
||||
// Severed
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// "On these days:" — seven circular toggles (S M T W T F S) with a
|
||||
/// human-readable summary, as in the reference rule editors.
|
||||
struct DayOfWeekPicker: View {
|
||||
@Binding var days: Set<Weekday>
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack {
|
||||
Text("On these days:")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
Spacer()
|
||||
Text(days.summary)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
ForEach(Weekday.displayOrder, id: \.self) { day in
|
||||
dayToggle(day)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
|
||||
}
|
||||
|
||||
private func dayToggle(_ day: Weekday) -> some View {
|
||||
let isOn = days.contains(day)
|
||||
return Button {
|
||||
if isOn {
|
||||
days.remove(day)
|
||||
} else {
|
||||
days.insert(day)
|
||||
}
|
||||
} label: {
|
||||
Text(day.shortLabel)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(isOn ? .black : Theme.textSecondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.background(isOn ? Color.white : Theme.surfaceElevated, in: Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("dayToggle-\(day.rawValue)")
|
||||
.accessibilityLabel(day.abbreviation)
|
||||
.accessibilityAddTraits(isOn ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
@Previewable @State var days = Weekday.weekdays
|
||||
DayOfWeekPicker(days: $days)
|
||||
.padding()
|
||||
.background(Theme.background)
|
||||
}
|
||||
54
Severed/Views/Components/HoldToCommitButton.swift
Normal file
54
Severed/Views/Components/HoldToCommitButton.swift
Normal file
@@ -0,0 +1,54 @@
|
||||
//
|
||||
// HoldToCommitButton.swift
|
||||
// Severed
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// The deliberate-friction commit button: the user must press and hold for a
|
||||
/// second to save a rule, mirroring the reference app's "Hold to Commit".
|
||||
struct HoldToCommitButton: View {
|
||||
var title = "Hold to Commit"
|
||||
var holdDuration: Double = 1.0
|
||||
let action: () -> Void
|
||||
|
||||
@State private var progress: Double = 0
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 28)
|
||||
.fill(Theme.surfaceElevated)
|
||||
GeometryReader { proxy in
|
||||
RoundedRectangle(cornerRadius: 28)
|
||||
.fill(Theme.commitGradient)
|
||||
.opacity(0.85)
|
||||
.frame(width: proxy.size.width * progress)
|
||||
.animation(.linear(duration: 0.1), value: progress == 0)
|
||||
}
|
||||
Text(title)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
.frame(height: 56)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 28))
|
||||
.contentShape(RoundedRectangle(cornerRadius: 28))
|
||||
.onLongPressGesture(minimumDuration: holdDuration) {
|
||||
progress = 0
|
||||
action()
|
||||
} onPressingChanged: { pressing in
|
||||
withAnimation(.linear(duration: pressing ? holdDuration : 0.2)) {
|
||||
progress = pressing ? 1 : 0
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityIdentifier("holdToCommitButton")
|
||||
.accessibilityLabel(title)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
HoldToCommitButton { }
|
||||
.padding()
|
||||
.background(Theme.background)
|
||||
}
|
||||
139
Severed/Views/Onboarding/OnboardingView.swift
Normal file
139
Severed/Views/Onboarding/OnboardingView.swift
Normal file
@@ -0,0 +1,139 @@
|
||||
//
|
||||
// OnboardingView.swift
|
||||
// Severed
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Two-step onboarding: a welcome screen, then the Screen Time permission
|
||||
/// request. Onboarding only completes once authorization is approved — the
|
||||
/// app cannot block anything without it.
|
||||
struct OnboardingView: View {
|
||||
@Environment(ScreenTimeAuthorization.self) private var authorization
|
||||
@Environment(\.openURL) private var openURL
|
||||
let onComplete: () -> Void
|
||||
|
||||
private enum Step {
|
||||
case welcome
|
||||
case permission
|
||||
}
|
||||
|
||||
@State private var step = Step.welcome
|
||||
@State private var isRequesting = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
switch step {
|
||||
case .welcome: welcome
|
||||
case .permission: permission
|
||||
}
|
||||
Spacer()
|
||||
footer
|
||||
}
|
||||
.padding(.horizontal, 28)
|
||||
.padding(.bottom, 24)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.foregroundStyle(.white)
|
||||
.background(Theme.background)
|
||||
}
|
||||
|
||||
private var welcome: some View {
|
||||
VStack(spacing: 18) {
|
||||
Image(systemName: "scissors")
|
||||
.font(.system(size: 56, weight: .semibold))
|
||||
.foregroundStyle(Theme.accent)
|
||||
Text("Severed")
|
||||
.font(.system(size: 38, weight: .bold))
|
||||
Text("Block your most distracting apps with rules that keep you honest — on a schedule, with no way out when you choose Hard Mode.")
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
|
||||
private var permission: some View {
|
||||
VStack(spacing: 24) {
|
||||
Image(systemName: "hourglass")
|
||||
.font(.system(size: 48, weight: .semibold))
|
||||
.foregroundStyle(Theme.accent)
|
||||
Text("Allow Screen Time Access")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.multilineTextAlignment(.center)
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
bullet("shield.fill", "Severed uses Apple's Screen Time framework to block the apps you choose.")
|
||||
bullet("hand.raised.fill", "Your app activity stays on this device and is never collected.")
|
||||
bullet("gearshape.fill", "You can change this anytime in Settings.")
|
||||
}
|
||||
if authorization.status == .denied || authorization.lastRequestFailed {
|
||||
VStack(spacing: 10) {
|
||||
Text("Screen Time access was declined. Severed can't block apps without it.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(Theme.destructive)
|
||||
.multilineTextAlignment(.center)
|
||||
.accessibilityIdentifier("permissionDeniedLabel")
|
||||
Button("Open Settings") {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
openURL(url)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Theme.accent)
|
||||
.accessibilityIdentifier("openSettingsButton")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func bullet(_ systemImage: String, _ text: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Theme.accent)
|
||||
.frame(width: 22)
|
||||
Text(text)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var footer: some View {
|
||||
switch step {
|
||||
case .welcome:
|
||||
pillButton("Continue", identifier: "onboardingContinueButton") {
|
||||
step = .permission
|
||||
}
|
||||
case .permission:
|
||||
pillButton(
|
||||
isRequesting ? "Requesting…" : "Allow Screen Time Access",
|
||||
identifier: "allowScreenTimeButton"
|
||||
) {
|
||||
guard !isRequesting else { return }
|
||||
isRequesting = true
|
||||
Task {
|
||||
await authorization.request()
|
||||
isRequesting = false
|
||||
if authorization.status == .approved {
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pillButton(
|
||||
_ title: String, identifier: String, action: @escaping () -> Void
|
||||
) -> some View {
|
||||
Button(action: action) {
|
||||
Text(title)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(.black)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 54)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 27))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier(identifier)
|
||||
}
|
||||
}
|
||||
32
Severed/Views/RootView.swift
Normal file
32
Severed/Views/RootView.swift
Normal file
@@ -0,0 +1,32 @@
|
||||
//
|
||||
// RootView.swift
|
||||
// Severed
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Gates the app on onboarding: until the user has walked through the welcome
|
||||
/// and Screen Time permission steps, nothing else is reachable.
|
||||
struct RootView: View {
|
||||
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
|
||||
@Environment(ScreenTimeAuthorization.self) private var authorization
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if hasCompletedOnboarding {
|
||||
AppsHomeView()
|
||||
} else {
|
||||
OnboardingView {
|
||||
hasCompletedOnboarding = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
// Pick up permission changes made in Settings while we were backgrounded.
|
||||
if phase == .active {
|
||||
authorization.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
94
Severed/Views/Rules/AppSelectionSheet.swift
Normal file
94
Severed/Views/Rules/AppSelectionSheet.swift
Normal file
@@ -0,0 +1,94 @@
|
||||
//
|
||||
// AppSelectionSheet.swift
|
||||
// Severed
|
||||
//
|
||||
|
||||
import FamilyControls
|
||||
import SwiftUI
|
||||
|
||||
/// App/category/website selection for a rule. Wraps Apple's
|
||||
/// `FamilyActivityPicker` (the system-sanctioned way to choose apps without
|
||||
/// seeing their identities) and adds the reference app's Block / Allow Only
|
||||
/// mode switch.
|
||||
struct AppSelectionSheet: View {
|
||||
@Binding var draft: RuleDraft
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var selection: FamilyActivitySelection
|
||||
@State private var mode: SelectionMode
|
||||
|
||||
init(draft: Binding<RuleDraft>) {
|
||||
self._draft = draft
|
||||
self._selection = State(
|
||||
initialValue: AppSelectionCodec.decode(draft.wrappedValue.selectionData)
|
||||
)
|
||||
self._mode = State(initialValue: draft.wrappedValue.selectionMode)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
CircleIconButton(systemImage: "chevron.left") { dismiss() }
|
||||
.accessibilityIdentifier("selectionBackButton")
|
||||
Spacer()
|
||||
CircleIconButton(systemImage: "checkmark", tint: .black) { save() }
|
||||
.background(Theme.accent, in: Circle())
|
||||
.accessibilityIdentifier("confirmSelectionButton")
|
||||
}
|
||||
.overlay(
|
||||
Text("Selected")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 18)
|
||||
|
||||
Picker("Mode", selection: $mode) {
|
||||
ForEach(SelectionMode.allCases, id: \.self) { mode in
|
||||
Text(mode.displayName).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 16)
|
||||
.accessibilityIdentifier("selectionModePicker")
|
||||
|
||||
FamilyActivityPicker(selection: $selection)
|
||||
.padding(.top, 4)
|
||||
|
||||
VStack(spacing: 10) {
|
||||
Text(selectionSummary)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
.accessibilityIdentifier("selectionCountLabel")
|
||||
Button {
|
||||
save()
|
||||
} label: {
|
||||
Text("Save")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(.black)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 54)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 27))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("saveSelectionButton")
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
.background(Theme.background)
|
||||
}
|
||||
|
||||
private var selectionSummary: String {
|
||||
let count = AppSelectionCodec.count(of: selection)
|
||||
return count == 1 ? "1 App Selected" : "\(count) Apps Selected"
|
||||
}
|
||||
|
||||
private func save() {
|
||||
draft.selectionData = AppSelectionCodec.encode(selection)
|
||||
draft.selectionCount = AppSelectionCodec.count(of: selection)
|
||||
draft.selectionMode = mode
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
157
Severed/Views/Rules/NewRuleSheet.swift
Normal file
157
Severed/Views/Rules/NewRuleSheet.swift
Normal file
@@ -0,0 +1,157 @@
|
||||
//
|
||||
// NewRuleSheet.swift
|
||||
// Severed
|
||||
//
|
||||
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
/// "New Rule": the three rule-type cards up top, then the preset gallery.
|
||||
/// Picking either one morphs the sheet into the editor; committing saves the
|
||||
/// rule and closes the sheet.
|
||||
struct NewRuleSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@State private var pendingDraft: RuleDraft?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let pendingDraft {
|
||||
RuleEditorView(
|
||||
mode: .create,
|
||||
draft: pendingDraft,
|
||||
onBack: { self.pendingDraft = nil },
|
||||
onCommit: { draft in
|
||||
modelContext.insert(draft.makeRule())
|
||||
dismiss()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
chooser
|
||||
}
|
||||
}
|
||||
.background(Theme.background)
|
||||
}
|
||||
|
||||
private var chooser: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
CircleIconButton(systemImage: "xmark") { dismiss() }
|
||||
.accessibilityIdentifier("closeNewRuleButton")
|
||||
Spacer()
|
||||
}
|
||||
.overlay(
|
||||
Text("New Rule")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 18)
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 26) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(RuleKind.allCases, id: \.self) { kind in
|
||||
kindCard(kind)
|
||||
}
|
||||
}
|
||||
ForEach(RulePresetSection.all) { section in
|
||||
presetSection(section)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 30)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
private func kindCard(_ kind: RuleKind) -> some View {
|
||||
Button {
|
||||
pendingDraft = RuleDraft(kind: kind)
|
||||
} label: {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: kind.symbolName)
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(Theme.accent)
|
||||
.frame(height: 26)
|
||||
Text(kind.displayName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
Text(kind.exampleText)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 18)
|
||||
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("ruleKind-\(kind.rawValue)")
|
||||
}
|
||||
|
||||
private func presetSection(_ section: RulePresetSection) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(section.title)
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
Text(section.subtitle)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
LazyVGrid(
|
||||
columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible())],
|
||||
spacing: 12
|
||||
) {
|
||||
ForEach(section.presets) { preset in
|
||||
presetCard(preset)
|
||||
}
|
||||
}
|
||||
.padding(.top, 12)
|
||||
}
|
||||
}
|
||||
|
||||
private func presetCard(_ preset: RulePreset) -> some View {
|
||||
Button {
|
||||
pendingDraft = RuleDraft(preset: preset)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
RuleIconPair(kind: .schedule)
|
||||
Spacer()
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: preset.symbolName)
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
.padding(.bottom, 8)
|
||||
Text(preset.schedule.timeRangeLabel)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
Text(preset.name)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
HStack {
|
||||
Text("Block")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
Spacer()
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.frame(height: 190)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [preset.gradientTop, preset.gradientBottom],
|
||||
startPoint: .top, endPoint: .bottom
|
||||
),
|
||||
in: RoundedRectangle(cornerRadius: 20)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("preset-\(preset.id)")
|
||||
}
|
||||
}
|
||||
178
Severed/Views/Rules/RuleDetailSheet.swift
Normal file
178
Severed/Views/Rules/RuleDetailSheet.swift
Normal file
@@ -0,0 +1,178 @@
|
||||
//
|
||||
// RuleDetailSheet.swift
|
||||
// Severed
|
||||
//
|
||||
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
/// The card-style rule summary shown when tapping a rule: icon pair, live
|
||||
/// status, the key facts, and "Edit Rule" — which morphs the sheet into the
|
||||
/// editor, as in the reference app. A hard-locked rule shows a lock notice
|
||||
/// instead of the edit button.
|
||||
struct RuleDetailSheet: View {
|
||||
let rule: BlockingRule
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@State private var isEditing = false
|
||||
@State private var pendingDeletion = false
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.periodic(from: .now, by: 30)) { timeline in
|
||||
if isEditing {
|
||||
RuleEditorView(
|
||||
mode: .edit(isEnabled: rule.isEnabled),
|
||||
draft: RuleDraft(rule: rule),
|
||||
onBack: { isEditing = false },
|
||||
onCommit: { draft in
|
||||
draft.apply(to: rule)
|
||||
isEditing = false
|
||||
},
|
||||
onToggleEnabled: {
|
||||
rule.isEnabled.toggle()
|
||||
rule.pausedUntil = nil
|
||||
isEditing = false
|
||||
},
|
||||
onDelete: {
|
||||
pendingDeletion = true
|
||||
dismiss()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
detail(now: timeline.date)
|
||||
}
|
||||
}
|
||||
.background(Theme.background)
|
||||
.onDisappear {
|
||||
if pendingDeletion {
|
||||
modelContext.delete(rule)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func detail(now: Date) -> some View {
|
||||
let status = rule.status(at: now)
|
||||
return VStack(spacing: 0) {
|
||||
HStack {
|
||||
CircleIconButton(systemImage: "xmark") { dismiss() }
|
||||
.accessibilityIdentifier("closeDetailButton")
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 18)
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
RuleIconPair(kind: rule.kind, isActive: status.isActive)
|
||||
VStack(spacing: 6) {
|
||||
Text("\(rule.kind.displayName), \(status.label(relativeTo: now))")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
.accessibilityIdentifier("detailStatusLabel")
|
||||
Text(rule.name)
|
||||
.font(.system(size: 26, weight: .bold))
|
||||
.accessibilityIdentifier("detailRuleName")
|
||||
}
|
||||
detailRows
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
footer(now: now)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
private var detailRows: some View {
|
||||
VStack(spacing: 0) {
|
||||
switch rule.kind {
|
||||
case .schedule:
|
||||
row("During this time", rule.schedule.timeRangeLabel)
|
||||
divider
|
||||
row("On these days", rule.days.summary)
|
||||
divider
|
||||
row(rule.selectionMode.displayName, appCountLabel)
|
||||
divider
|
||||
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
|
||||
case .timeLimit:
|
||||
row("When I use", appCountLabel)
|
||||
divider
|
||||
row("For this long", "\(rule.dailyLimitMinutes)m daily")
|
||||
divider
|
||||
row("On these days", rule.days.summary)
|
||||
divider
|
||||
row("Then block until", "Tomorrow")
|
||||
divider
|
||||
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
|
||||
case .openLimit:
|
||||
row("When I open", appCountLabel)
|
||||
divider
|
||||
row("More than", "\(rule.maxOpens) opens daily")
|
||||
divider
|
||||
row("On these days", rule.days.summary)
|
||||
divider
|
||||
row("Then block until", "Tomorrow")
|
||||
divider
|
||||
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
|
||||
}
|
||||
}
|
||||
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
|
||||
}
|
||||
|
||||
private var appCountLabel: String {
|
||||
rule.selectionCount == 1 ? "1 App" : "\(rule.selectionCount) Apps"
|
||||
}
|
||||
|
||||
private func row(_ label: String, _ value: String) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityIdentifier("detailRow-\(label)")
|
||||
}
|
||||
|
||||
private var divider: some View {
|
||||
Divider().background(.white.opacity(0.06)).padding(.leading, 16)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func footer(now: Date) -> some View {
|
||||
if RulePolicy.canEdit(rule, at: now) {
|
||||
Button {
|
||||
isEditing = true
|
||||
} label: {
|
||||
Label("Edit Rule", systemImage: "pencil")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(.black)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 54)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 27))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("editRuleButton")
|
||||
} else {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "lock.fill")
|
||||
Text("Hard Mode is on — this rule is locked until the block ends.")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
}
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityIdentifier("hardModeLockedNotice")
|
||||
}
|
||||
}
|
||||
}
|
||||
297
Severed/Views/Rules/RuleEditorView.swift
Normal file
297
Severed/Views/Rules/RuleEditorView.swift
Normal file
@@ -0,0 +1,297 @@
|
||||
//
|
||||
// RuleEditorView.swift
|
||||
// Severed
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// The rule editor used both for creation (Hold to Commit) and editing
|
||||
/// (Done / Disable Rule / Delete Rule). Sections adapt to the rule kind,
|
||||
/// mirroring the reference app's "In the Zone" and "Time Keeper" editors.
|
||||
struct RuleEditorView: View {
|
||||
enum Mode: Equatable {
|
||||
case create
|
||||
case edit(isEnabled: Bool)
|
||||
}
|
||||
|
||||
let mode: Mode
|
||||
@State var draft: RuleDraft
|
||||
var onBack: () -> Void
|
||||
var onCommit: (RuleDraft) -> Void
|
||||
var onToggleEnabled: (() -> Void)?
|
||||
var onDelete: (() -> Void)?
|
||||
|
||||
@State private var showingRename = false
|
||||
@State private var showingAppPicker = false
|
||||
@State private var renameText = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
ScrollView {
|
||||
VStack(spacing: 18) {
|
||||
sections
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
footer
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.background(Theme.background)
|
||||
.alert("Rule Name", isPresented: $showingRename) {
|
||||
TextField("Name", text: $renameText)
|
||||
Button("OK") {
|
||||
let trimmed = renameText.trimmingCharacters(in: .whitespaces)
|
||||
if !trimmed.isEmpty {
|
||||
draft.name = trimmed
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
.sheet(isPresented: $showingAppPicker) {
|
||||
AppSelectionSheet(draft: $draft)
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
CircleIconButton(systemImage: "chevron.left", action: onBack)
|
||||
.accessibilityIdentifier("editorBackButton")
|
||||
Spacer()
|
||||
CircleIconButton(systemImage: "pencil") {
|
||||
renameText = draft.name
|
||||
showingRename = true
|
||||
}
|
||||
.accessibilityIdentifier("renameButton")
|
||||
}
|
||||
.overlay(
|
||||
Text(draft.name)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, 56)
|
||||
.accessibilityIdentifier("ruleEditorTitle")
|
||||
)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 18)
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
@ViewBuilder
|
||||
private var sections: some View {
|
||||
switch draft.kind {
|
||||
case .schedule:
|
||||
timeWindowSection
|
||||
DayOfWeekPicker(days: $draft.days)
|
||||
appsSection(header: "Apps are blocked")
|
||||
hardModeSection
|
||||
case .timeLimit:
|
||||
appsSection(header: "When I use")
|
||||
budgetSection(
|
||||
title: "For this long",
|
||||
value: "\(draft.dailyLimitMinutes)m",
|
||||
stepperID: "dailyLimitStepper",
|
||||
onIncrement: { draft.dailyLimitMinutes = min(240, draft.dailyLimitMinutes + 15) },
|
||||
onDecrement: { draft.dailyLimitMinutes = max(15, draft.dailyLimitMinutes - 15) }
|
||||
)
|
||||
DayOfWeekPicker(days: $draft.days)
|
||||
blockUntilSection
|
||||
hardModeSection
|
||||
case .openLimit:
|
||||
appsSection(header: "When I open")
|
||||
budgetSection(
|
||||
title: "More than",
|
||||
value: "\(draft.maxOpens) opens",
|
||||
stepperID: "maxOpensStepper",
|
||||
onIncrement: { draft.maxOpens = min(50, draft.maxOpens + 1) },
|
||||
onDecrement: { draft.maxOpens = max(1, draft.maxOpens - 1) }
|
||||
)
|
||||
DayOfWeekPicker(days: $draft.days)
|
||||
blockUntilSection
|
||||
hardModeSection
|
||||
}
|
||||
}
|
||||
|
||||
private var timeWindowSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
sectionHeader(systemImage: "calendar", title: "During this time")
|
||||
VStack(spacing: 0) {
|
||||
timeRow(label: "From", minutes: $draft.startMinutes, identifier: "fromTimePicker")
|
||||
Divider().background(.white.opacity(0.06)).padding(.leading, 16)
|
||||
timeRow(label: "To", minutes: $draft.endMinutes, identifier: "toTimePicker")
|
||||
}
|
||||
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
|
||||
}
|
||||
}
|
||||
|
||||
private func timeRow(label: String, minutes: Binding<Int>, identifier: String) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
Spacer()
|
||||
DatePicker("", selection: timeBinding(minutes), displayedComponents: .hourAndMinute)
|
||||
.labelsHidden()
|
||||
.colorScheme(.dark)
|
||||
.accessibilityIdentifier(identifier)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private func appsSection(header: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
sectionHeader(systemImage: "shield.fill", title: header)
|
||||
Button {
|
||||
showingAppPicker = true
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Selected Apps")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
Spacer()
|
||||
Text(draft.selectionCount == 1 ? "1 App" : "\(draft.selectionCount) Apps")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
}
|
||||
.padding(16)
|
||||
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("selectedAppsRow")
|
||||
}
|
||||
}
|
||||
|
||||
private func budgetSection(
|
||||
title: String,
|
||||
value: String,
|
||||
stepperID: String,
|
||||
onIncrement: @escaping () -> Void,
|
||||
onDecrement: @escaping () -> Void
|
||||
) -> some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
Text("Daily")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
}
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.accessibilityIdentifier("\(stepperID)Value")
|
||||
Stepper("", onIncrement: onIncrement, onDecrement: onDecrement)
|
||||
.labelsHidden()
|
||||
.accessibilityIdentifier(stepperID)
|
||||
}
|
||||
.padding(16)
|
||||
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
|
||||
}
|
||||
|
||||
private var blockUntilSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
sectionHeader(systemImage: "shield.fill", title: "Then block app")
|
||||
HStack {
|
||||
Text("Until")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
Spacer()
|
||||
Text("Tomorrow")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
}
|
||||
.padding(16)
|
||||
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
|
||||
}
|
||||
}
|
||||
|
||||
private var hardModeSection: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Hard Mode")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
Text("No unblocks allowed")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
}
|
||||
Spacer()
|
||||
Toggle("", isOn: $draft.hardMode)
|
||||
.labelsHidden()
|
||||
.tint(Theme.accent)
|
||||
.accessibilityIdentifier("hardModeToggle")
|
||||
}
|
||||
.padding(16)
|
||||
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
|
||||
}
|
||||
|
||||
private func sectionHeader(systemImage: String, title: String) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
Text(title)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
}
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
|
||||
// MARK: - Footer
|
||||
|
||||
@ViewBuilder
|
||||
private var footer: some View {
|
||||
switch mode {
|
||||
case .create:
|
||||
HoldToCommitButton {
|
||||
onCommit(draft)
|
||||
}
|
||||
case .edit(let isEnabled):
|
||||
VStack(spacing: 12) {
|
||||
Button {
|
||||
onCommit(draft)
|
||||
} label: {
|
||||
Label("Done", systemImage: "checkmark")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(.black)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 54)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 27))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("doneButton")
|
||||
|
||||
HStack(spacing: 24) {
|
||||
Button(isEnabled ? "Disable Rule" : "Enable Rule") {
|
||||
onToggleEnabled?()
|
||||
}
|
||||
.accessibilityIdentifier("toggleEnabledButton")
|
||||
|
||||
Button("Delete Rule") {
|
||||
onDelete?()
|
||||
}
|
||||
.accessibilityIdentifier("deleteRuleButton")
|
||||
}
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Theme.destructive)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func timeBinding(_ minutes: Binding<Int>) -> Binding<Date> {
|
||||
Binding {
|
||||
let dayStart = Calendar.current.startOfDay(for: .now)
|
||||
return Calendar.current.date(byAdding: .minute, value: minutes.wrappedValue, to: dayStart)
|
||||
?? .now
|
||||
} set: { newDate in
|
||||
let components = Calendar.current.dateComponents([.hour, .minute], from: newDate)
|
||||
minutes.wrappedValue = (components.hour ?? 0) * 60 + (components.minute ?? 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
73
Severed/Views/Theme.swift
Normal file
73
Severed/Views/Theme.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
//
|
||||
// Theme.swift
|
||||
// Severed
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Dark, green-tinted palette matching the reference app.
|
||||
enum Theme {
|
||||
static let background = Color(red: 0.03, green: 0.05, blue: 0.04)
|
||||
static let surface = Color.white.opacity(0.07)
|
||||
static let surfaceElevated = Color.white.opacity(0.12)
|
||||
static let accent = Color(red: 0.66, green: 0.88, blue: 0.50)
|
||||
static let activeCardTop = Color(red: 0.10, green: 0.20, blue: 0.12)
|
||||
static let activeCardBottom = Color(red: 0.05, green: 0.10, blue: 0.06)
|
||||
static let textSecondary = Color.white.opacity(0.6)
|
||||
static let textTertiary = Color.white.opacity(0.4)
|
||||
static let destructive = Color(red: 1.0, green: 0.32, blue: 0.36)
|
||||
|
||||
static let commitGradient = LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.35, green: 0.65, blue: 0.85),
|
||||
Color(red: 0.45, green: 0.75, blue: 0.55),
|
||||
Color(red: 0.85, green: 0.80, blue: 0.45),
|
||||
],
|
||||
startPoint: .leading, endPoint: .trailing
|
||||
)
|
||||
}
|
||||
|
||||
/// Circular chrome button used in sheet headers (✕, ‹, ✎).
|
||||
struct CircleIconButton: View {
|
||||
let systemImage: String
|
||||
var tint: Color = .white
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(tint)
|
||||
.frame(width: 38, height: 38)
|
||||
.background(Theme.surfaceElevated, in: Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// The "calendar → shield" icon pair shown on rule cards, details, and presets.
|
||||
struct RuleIconPair: View {
|
||||
let kind: RuleKind
|
||||
var isActive = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
iconTile(systemImage: kind.symbolName, tinted: isActive)
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(Theme.textTertiary)
|
||||
iconTile(systemImage: "shield.fill", tinted: isActive)
|
||||
}
|
||||
}
|
||||
|
||||
private func iconTile(systemImage: String, tinted: Bool) -> some View {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(tinted ? Theme.accent : .white.opacity(0.7))
|
||||
.frame(width: 32, height: 32)
|
||||
.background(
|
||||
(tinted ? Theme.accent.opacity(0.18) : Theme.surfaceElevated),
|
||||
in: RoundedRectangle(cornerRadius: 9)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user