From 2d609043f35faf272051f367785834649cd80ef0 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Fri, 12 Jun 2026 15:14:01 -0400 Subject: [PATCH] refactor: re-skin UI with native iOS components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the Opal-style custom presentation with the bare iOS design language, keeping the backend, flows, and accessibility identifiers: - Home: NavigationStack + List; rules now presented as list rows with kind icon, block summary, and trailing live status; '+' in the toolbar - New Rule: plain list of rule types and presets; editor still pushed via navigationDestination - Editor: native Form (DatePicker rows, day circles with summary in the section header, toggle rows with footers, stepper rows); create commits with a prominent 'Add Rule' button replacing Hold to Commit; edit uses toolbar Done plus red Disable/Delete rows - Detail: sheet with inline title + status caption, LabeledContent rows; Edit Rule pushes the editor natively - Default color scheme: drop forced dark mode and custom tint; system appearance and accent throughout - Delete Theme, HoldToCommitButton, RuleCardView and custom chrome - Use concrete Color.primary/secondary in button row labels (the hierarchical styles resolve against the button tint) - Tests: 93 passing; only delta is holdToCommitButton press → commitRuleButton tap in 5 call sites - Spec: add §6 mapping the original presentation to the native one --- Severed/SeveredApp.swift | 2 - Severed/Views/Apps/AppsHomeView.swift | 189 ++++---- Severed/Views/Apps/RuleCardView.swift | 90 ---- .../Views/Components/DayOfWeekPicker.swift | 49 +- .../Views/Components/HoldToCommitButton.swift | 54 --- Severed/Views/Onboarding/OnboardingView.swift | 51 +-- Severed/Views/Rules/AppSelectionSheet.swift | 94 ++-- Severed/Views/Rules/NewRuleSheet.swift | 186 +++----- Severed/Views/Rules/RuleDetailSheet.swift | 192 +++----- Severed/Views/Rules/RuleEditorView.swift | 429 +++++++----------- Severed/Views/Theme.swift | 73 --- SeveredUITests/RuleCreationUITests.swift | 10 +- docs/RULES_FEATURE_SPEC.md | 20 + 13 files changed, 525 insertions(+), 914 deletions(-) delete mode 100644 Severed/Views/Apps/RuleCardView.swift delete mode 100644 Severed/Views/Components/HoldToCommitButton.swift delete mode 100644 Severed/Views/Theme.swift diff --git a/Severed/SeveredApp.swift b/Severed/SeveredApp.swift index c67594a..a510100 100644 --- a/Severed/SeveredApp.swift +++ b/Severed/SeveredApp.swift @@ -53,8 +53,6 @@ struct SeveredApp: App { RootView() .environment(authorization) .environment(enforcer) - .preferredColorScheme(.dark) - .tint(Theme.accent) } .modelContainer(container) } diff --git a/Severed/Views/Apps/AppsHomeView.swift b/Severed/Views/Apps/AppsHomeView.swift index 44063e1..8b09e7b 100644 --- a/Severed/Views/Apps/AppsHomeView.swift +++ b/Severed/Views/Apps/AppsHomeView.swift @@ -6,8 +6,9 @@ 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". +/// Home screen using plain iOS components: a List with a "Blocked Apps" +/// section (what's blocking right now) and a "Rules" section listing every +/// rule with its live status. "+" in the toolbar creates a new rule. struct AppsHomeView: View { @Environment(\.modelContext) private var modelContext @Environment(RuleEnforcer.self) private var enforcer @@ -21,27 +22,23 @@ struct AppsHomeView: View { 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) + ruleList(now: timeline.date) + } + .navigationTitle("Apps") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("New Rule", systemImage: "plus") { + showingNewRule = true } - .padding(.horizontal, 20) - .padding(.top, 8) + .accessibilityIdentifier("newRuleButton") } } - .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 ?? "")?", @@ -74,30 +71,34 @@ struct AppsHomeView: View { } } - // 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 ruleList(now: Date) -> some View { + List { + blockedSection(now: now) + rulesSection(now: now) } } - private func blockedTile(for rule: BlockingRule, now: Date) -> some View { + // MARK: - Blocked Apps + + @ViewBuilder + private func blockedSection(now: Date) -> some View { + let blocking = rules.filter { $0.status(at: now).isActive } + Section { + if blocking.isEmpty { + Text("Nothing is blocked right now.") + .foregroundStyle(.secondary) + .accessibilityIdentifier("nothingBlockedLabel") + } else { + ForEach(blocking) { rule in + blockedRow(for: rule, now: now) + } + } + } header: { + Text("Blocked Apps").textCase(nil) + } + } + + private func blockedRow(for rule: BlockingRule, now: Date) -> some View { Button { if RulePolicy.canUnblock(rule, at: now) { unblockCandidate = rule @@ -105,75 +106,86 @@ struct AppsHomeView: View { hardModeBlockedAttempt = true } } label: { - VStack(spacing: 6) { + HStack { 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) - ) + .foregroundStyle(rule.hardMode ? .red : .secondary) + .frame(width: 28) + Text(rule.name) + .foregroundStyle(Color.primary) + Spacer() Text("Unblock") - .font(.system(size: 12)) - .foregroundStyle(Theme.textSecondary) + .foregroundStyle(.tint) } } - .buttonStyle(.plain) .accessibilityIdentifier("blockedTile-\(rule.name)") } // MARK: - Rules + @ViewBuilder 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") - } + Section { if rules.isEmpty { - emptyRulesCard + VStack(alignment: .leading, spacing: 4) { + Text("No rules yet") + .font(.headline) + Text("Create a rule to block distracting apps on a schedule.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("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)") - } - } + ForEach(rules) { rule in + ruleRow(for: rule, now: now) } } + } header: { + Text("Rules").textCase(nil) } } - 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) + private func ruleRow(for rule: BlockingRule, now: Date) -> some View { + let status = rule.status(at: now) + return Button { + detailRule = rule + } label: { + HStack { + Image(systemName: rule.kind.symbolName) + .foregroundStyle(.tint) + .frame(width: 28) + VStack(alignment: .leading, spacing: 2) { + Text(rule.name) + .foregroundStyle(Color.primary) + Text(blockSummary(for: rule)) + .font(.caption) + .foregroundStyle(Color.secondary) + } + Spacer() + Text(statusText(for: rule, status: status, now: now)) + .font(.subheadline) + .foregroundStyle(status.isActive ? .green : .secondary) + .accessibilityIdentifier("ruleStatus-\(rule.name)") + } + } + .accessibilityIdentifier("ruleCard-\(rule.name)") + } + + private func blockSummary(for rule: BlockingRule) -> String { + let apps = rule.selectionCount == 1 ? "1 app" : "\(rule.selectionCount) apps" + return "\(rule.selectionMode.displayName) · \(apps)" + } + + private func statusText(for rule: BlockingRule, status: RuleStatus, now: Date) -> 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" } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(18) - .background(Theme.surface, in: RoundedRectangle(cornerRadius: 22)) - .accessibilityElement(children: .combine) - .accessibilityIdentifier("emptyRulesCard") } // MARK: - Enforcement @@ -181,8 +193,9 @@ struct AppsHomeView: View { /// 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)" + "\($0.id)|\($0.isEnabled)|\($0.hardMode)|\($0.blockAdultContent)|" + + "\($0.startMinutes)|\($0.endMinutes)|\($0.dayNumbers)|" + + "\($0.selectionCount)|\($0.pausedUntil?.timeIntervalSince1970 ?? 0)" } .joined(separator: ",") } diff --git a/Severed/Views/Apps/RuleCardView.swift b/Severed/Views/Apps/RuleCardView.swift deleted file mode 100644 index 6d0dad6..0000000 --- a/Severed/Views/Apps/RuleCardView.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// 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) - } -} diff --git a/Severed/Views/Components/DayOfWeekPicker.swift b/Severed/Views/Components/DayOfWeekPicker.swift index e7fb765..8d1cb88 100644 --- a/Severed/Views/Components/DayOfWeekPicker.swift +++ b/Severed/Views/Components/DayOfWeekPicker.swift @@ -5,29 +5,19 @@ 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. +/// Seven circular day toggles (S M T W T F S) using system colors, meant to +/// sit inside a Form/List row. The day-set summary is shown by the enclosing +/// section header. struct DayOfWeekPicker: View { @Binding var days: Set 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) - } + HStack(spacing: 8) { + ForEach(Weekday.displayOrder, id: \.self) { day in + dayToggle(day) } } - .padding(16) - .background(Theme.surface, in: RoundedRectangle(cornerRadius: 18)) + .padding(.vertical, 4) } private func dayToggle(_ day: Weekday) -> some View { @@ -40,13 +30,16 @@ struct DayOfWeekPicker: View { } } label: { Text(day.shortLabel) - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(isOn ? .black : Theme.textSecondary) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(isOn ? Color.white : .secondary) .frame(maxWidth: .infinity) .aspectRatio(1, contentMode: .fit) - .background(isOn ? Color.white : Theme.surfaceElevated, in: Circle()) + .background( + isOn ? AnyShapeStyle(.tint) : AnyShapeStyle(Color(.tertiarySystemFill)), + in: Circle() + ) } - .buttonStyle(.plain) + .buttonStyle(.borderless) .accessibilityIdentifier("dayToggle-\(day.rawValue)") .accessibilityLabel(day.abbreviation) .accessibilityAddTraits(isOn ? .isSelected : []) @@ -55,7 +48,15 @@ struct DayOfWeekPicker: View { #Preview { @Previewable @State var days = Weekday.weekdays - DayOfWeekPicker(days: $days) - .padding() - .background(Theme.background) + Form { + Section { + DayOfWeekPicker(days: $days) + } header: { + HStack { + Text("On these days").textCase(nil) + Spacer() + Text(days.summary).textCase(nil) + } + } + } } diff --git a/Severed/Views/Components/HoldToCommitButton.swift b/Severed/Views/Components/HoldToCommitButton.swift deleted file mode 100644 index b90f3e2..0000000 --- a/Severed/Views/Components/HoldToCommitButton.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// 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) -} diff --git a/Severed/Views/Onboarding/OnboardingView.swift b/Severed/Views/Onboarding/OnboardingView.swift index 8751692..33d1361 100644 --- a/Severed/Views/Onboarding/OnboardingView.swift +++ b/Severed/Views/Onboarding/OnboardingView.swift @@ -5,9 +5,9 @@ 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. +/// Two-step onboarding using system styling: 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 @@ -31,23 +31,18 @@ struct OnboardingView: View { Spacer() footer } - .padding(.horizontal, 28) - .padding(.bottom, 24) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .foregroundStyle(.white) - .background(Theme.background) + .padding() } private var welcome: some View { - VStack(spacing: 18) { + VStack(spacing: 16) { Image(systemName: "scissors") - .font(.system(size: 56, weight: .semibold)) - .foregroundStyle(Theme.accent) + .font(.system(size: 56)) + .foregroundStyle(.tint) Text("Severed") - .font(.system(size: 38, weight: .bold)) + .font(.largeTitle.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) + .foregroundStyle(.secondary) .multilineTextAlignment(.center) } } @@ -55,10 +50,10 @@ struct OnboardingView: View { private var permission: some View { VStack(spacing: 24) { Image(systemName: "hourglass") - .font(.system(size: 48, weight: .semibold)) - .foregroundStyle(Theme.accent) + .font(.system(size: 48)) + .foregroundStyle(.tint) Text("Allow Screen Time Access") - .font(.system(size: 28, weight: .bold)) + .font(.title.bold()) .multilineTextAlignment(.center) VStack(alignment: .leading, spacing: 14) { bullet("shield.fill", "Severed uses Apple's Screen Time framework to block the apps you choose.") @@ -68,8 +63,8 @@ struct OnboardingView: View { 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) + .font(.footnote) + .foregroundStyle(.red) .multilineTextAlignment(.center) .accessibilityIdentifier("permissionDeniedLabel") Button("Open Settings") { @@ -77,8 +72,6 @@ struct OnboardingView: View { openURL(url) } } - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(Theme.accent) .accessibilityIdentifier("openSettingsButton") } } @@ -88,12 +81,11 @@ struct OnboardingView: View { 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) + .foregroundStyle(.tint) + .frame(width: 24) Text(text) - .font(.system(size: 15)) - .foregroundStyle(Theme.textSecondary) + .font(.subheadline) + .foregroundStyle(.secondary) } } @@ -127,13 +119,10 @@ struct OnboardingView: View { ) -> 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) + .buttonStyle(.borderedProminent) + .controlSize(.large) .accessibilityIdentifier(identifier) } } diff --git a/Severed/Views/Rules/AppSelectionSheet.swift b/Severed/Views/Rules/AppSelectionSheet.swift index 44df02a..b76dfbf 100644 --- a/Severed/Views/Rules/AppSelectionSheet.swift +++ b/Severed/Views/Rules/AppSelectionSheet.swift @@ -6,10 +6,8 @@ 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. +/// App/category/website selection wrapping Apple's `FamilyActivityPicker`, +/// presented as a plain sheet with a segmented Block / Allow Only control. struct AppSelectionSheet: View { @Binding var draft: RuleDraft @Environment(\.dismiss) private var dismiss @@ -26,58 +24,56 @@ struct AppSelectionSheet: View { } var body: some View { - VStack(spacing: 0) { - HStack { - CircleIconButton(systemImage: "chevron.left") { dismiss() } + NavigationStack { + VStack(spacing: 0) { + Picker("Mode", selection: $mode) { + ForEach(SelectionMode.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) + } + } + .pickerStyle(.segmented) + .padding(.horizontal) + .padding(.vertical, 8) + .accessibilityIdentifier("selectionModePicker") + + FamilyActivityPicker(selection: $selection) + } + .navigationTitle("Selected") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } .accessibilityIdentifier("selectionBackButton") - Spacer() - CircleIconButton(systemImage: "checkmark", tint: .black) { save() } - .background(Theme.accent, in: Circle()) + } + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + save() + } .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)) + .safeAreaInset(edge: .bottom) { + VStack(spacing: 8) { + Text(selectionSummary) + .font(.footnote) + .foregroundStyle(.secondary) + .accessibilityIdentifier("selectionCountLabel") + Button { + save() + } label: { + Text("Save") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .accessibilityIdentifier("saveSelectionButton") } - .buttonStyle(.plain) - .accessibilityIdentifier("saveSelectionButton") + .padding(.horizontal) + .padding(.bottom, 8) } - .padding(.horizontal, 20) - .padding(.bottom, 16) } - .background(Theme.background) } private var selectionSummary: String { diff --git a/Severed/Views/Rules/NewRuleSheet.swift b/Severed/Views/Rules/NewRuleSheet.swift index 2ec7310..692b75d 100644 --- a/Severed/Views/Rules/NewRuleSheet.swift +++ b/Severed/Views/Rules/NewRuleSheet.swift @@ -6,9 +6,8 @@ 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. +/// "New Rule" as a plain list: a Rule Type section, then the preset sections. +/// Picking either pushes the editor; committing saves and closes the sheet. struct NewRuleSheet: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext @@ -16,142 +15,97 @@ struct NewRuleSheet: View { var body: some View { NavigationStack { - chooser - .toolbar(.hidden, for: .navigationBar) - .navigationDestination(item: $pendingDraft) { draft in - RuleEditorView( - mode: .create, - draft: draft, - embedsInNavigationStack: true, - onCommit: { committed in - modelContext.insert(committed.makeRule()) - dismiss() - } - ) + List { + Section { + ForEach(RuleKind.allCases, id: \.self) { kind in + kindRow(kind) + } + } header: { + Text("Rule Type").textCase(nil) } - } - .background(Theme.background) - } - - private var chooser: some View { - VStack(spacing: 0) { - HStack { - CircleIconButton(systemImage: "xmark") { dismiss() } + ForEach(RulePresetSection.all) { section in + Section { + ForEach(section.presets) { preset in + presetRow(preset) + } + } header: { + VStack(alignment: .leading, spacing: 2) { + Text(section.title) + Text(section.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + .textCase(nil) + } + } + } + .navigationTitle("New Rule") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Close", 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) + } + .navigationDestination(item: $pendingDraft) { draft in + RuleEditorView( + mode: .create, + draft: draft, + onCommit: { committed in + modelContext.insert(committed.makeRule()) + dismiss() + } + ) } } - .foregroundStyle(.white) } - private func kindCard(_ kind: RuleKind) -> some View { + private func kindRow(_ kind: RuleKind) -> some View { Button { pendingDraft = RuleDraft(kind: kind) } label: { - VStack(spacing: 8) { + HStack { 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) + .foregroundStyle(.tint) + .frame(width: 28) + VStack(alignment: .leading, spacing: 2) { + Text(kind.displayName) + .foregroundStyle(Color.primary) + Text(kind.exampleText) + .font(.caption) + .foregroundStyle(Color.secondary) + } + Spacer() + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) } - .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 { + private func presetRow(_ preset: RulePreset) -> some View { Button { pendingDraft = RuleDraft(preset: preset) } label: { - VStack(alignment: .leading, spacing: 4) { - HStack { - RuleIconPair(kind: .schedule) - Spacer() + HStack { + Image(systemName: preset.symbolName) + .foregroundStyle(.tint) + .frame(width: 28) + VStack(alignment: .leading, spacing: 2) { + Text(preset.name) + .foregroundStyle(Color.primary) + Text("\(preset.schedule.timeRangeLabel) · \(preset.days.summary)") + .font(.caption) + .foregroundStyle(Color.secondary) } 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)) - } + Image(systemName: "plus.circle.fill") + .foregroundStyle(.tint) } - .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)") } } diff --git a/Severed/Views/Rules/RuleDetailSheet.swift b/Severed/Views/Rules/RuleDetailSheet.swift index ef380bf..2184486 100644 --- a/Severed/Views/Rules/RuleDetailSheet.swift +++ b/Severed/Views/Rules/RuleDetailSheet.swift @@ -6,10 +6,9 @@ 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. +/// Rule summary presented as a plain sheet: inline title with a live status +/// caption, the rule's facts as labeled rows, and "Edit Rule" — which pushes +/// the editor. A hard-locked rule shows a lock notice instead. struct RuleDetailSheet: View { let rule: BlockingRule @@ -19,12 +18,14 @@ struct RuleDetailSheet: View { @State private var pendingDeletion = false var body: some View { - TimelineView(.periodic(from: .now, by: 30)) { timeline in - if isEditing { + NavigationStack { + TimelineView(.periodic(from: .now, by: 30)) { timeline in + detailList(now: timeline.date) + } + .navigationDestination(isPresented: $isEditing) { RuleEditorView( mode: .edit(isEnabled: rule.isEnabled), draft: RuleDraft(rule: rule), - onBack: { isEditing = false }, onCommit: { draft in draft.apply(to: rule) isEditing = false @@ -39,11 +40,8 @@ struct RuleDetailSheet: View { dismiss() } ) - } else { - detail(now: timeline.date) } } - .background(Theme.background) .onDisappear { if pendingDeletion { modelContext.delete(rule) @@ -51,82 +49,77 @@ struct RuleDetailSheet: View { } } - private func detail(now: Date) -> some View { + private func detailList(now: Date) -> some View { let status = rule.status(at: now) - return VStack(spacing: 0) { - HStack { - CircleIconButton(systemImage: "xmark") { dismiss() } - .accessibilityIdentifier("closeDetailButton") - Spacer() + return List { + Section { + detailRows } - .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") + Section { + if RulePolicy.canEdit(rule, at: now) { + Button { + isEditing = true + } label: { + Label("Edit Rule", systemImage: "pencil") } - detailRows + .accessibilityIdentifier("editRuleButton") + } else { + Label( + "Hard Mode is on — this rule is locked until the block ends.", + systemImage: "lock.fill" + ) + .foregroundStyle(.secondary) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("hardModeLockedNotice") + } + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Close", systemImage: "xmark") { + dismiss() + } + .accessibilityIdentifier("closeDetailButton") + } + ToolbarItem(placement: .principal) { + VStack(spacing: 1) { + Text(rule.name) + .font(.headline) + .accessibilityIdentifier("detailRuleName") + Text("\(rule.kind.displayName), \(status.label(relativeTo: now))") + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityIdentifier("detailStatusLabel") } - .padding(.horizontal, 20) - .padding(.top, 8) } - - footer(now: now) - .padding(.horizontal, 20) - .padding(.bottom, 16) } - .foregroundStyle(.white) } + @ViewBuilder 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("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed") - 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("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed") - 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("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed") - divider - row("Unblocks allowed", rule.hardMode ? "No" : "Yes") - } + switch rule.kind { + case .schedule: + row("During this time", rule.schedule.timeRangeLabel) + row("On these days", rule.days.summary) + row(rule.selectionMode.displayName, appCountLabel) + row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed") + row("Unblocks allowed", rule.hardMode ? "No" : "Yes") + case .timeLimit: + row("When I use", appCountLabel) + row("For this long", "\(rule.dailyLimitMinutes)m daily") + row("On these days", rule.days.summary) + row("Then block until", "Tomorrow") + row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed") + row("Unblocks allowed", rule.hardMode ? "No" : "Yes") + case .openLimit: + row("When I open", appCountLabel) + row("More than", "\(rule.maxOpens) opens daily") + row("On these days", rule.days.summary) + row("Then block until", "Tomorrow") + row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed") + row("Unblocks allowed", rule.hardMode ? "No" : "Yes") } - .background(Theme.surface, in: RoundedRectangle(cornerRadius: 18)) } private var appCountLabel: String { @@ -134,51 +127,8 @@ struct RuleDetailSheet: View { } 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)) + LabeledContent(label, value: value) .accessibilityElement(children: .combine) - .accessibilityIdentifier("hardModeLockedNotice") - } + .accessibilityIdentifier("detailRow-\(label)") } } diff --git a/Severed/Views/Rules/RuleEditorView.swift b/Severed/Views/Rules/RuleEditorView.swift index d07f587..d63a03d 100644 --- a/Severed/Views/Rules/RuleEditorView.swift +++ b/Severed/Views/Rules/RuleEditorView.swift @@ -5,9 +5,10 @@ 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. +/// The rule editor as a plain Form, always pushed inside a NavigationStack +/// (New Rule flow and detail editing both push it). Creation commits with a +/// prominent "Add Rule" button; editing uses a toolbar Done plus red +/// Disable/Delete rows. struct RuleEditorView: View { enum Mode: Equatable { case create @@ -16,11 +17,6 @@ struct RuleEditorView: View { let mode: Mode @State var draft: RuleDraft - /// True when pushed inside a NavigationStack (New Rule flow): the editor - /// then uses native navigation chrome (system back button, swipe-back) - /// instead of its custom header. - var embedsInNavigationStack = false - var onBack: () -> Void = {} var onCommit: (RuleDraft) -> Void var onToggleEnabled: (() -> Void)? var onDelete: (() -> Void)? @@ -30,54 +26,62 @@ struct RuleEditorView: View { @State private var renameText = "" var body: some View { - if embedsInNavigationStack { - core - .navigationBarTitleDisplayMode(.inline) - .toolbarBackground(.hidden, for: .navigationBar) - .toolbar { - ToolbarItem(placement: .principal) { - Text(draft.name) - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(.white) - .lineLimit(1) - .accessibilityIdentifier("ruleEditorTitle") + Form { + sections + if case .edit(let isEnabled) = mode { + Section { + Button(isEnabled ? "Disable Rule" : "Enable Rule") { + onToggleEnabled?() } - ToolbarItem(placement: .topBarTrailing) { - Button { - renameText = draft.name - showingRename = true - } label: { - Image(systemName: "pencil") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(.white) - } - .accessibilityIdentifier("renameButton") - } - } - } else { - core - } - } + .foregroundStyle(.red) + .accessibilityIdentifier("toggleEnabledButton") - private var core: some View { - VStack(spacing: 0) { - if !embedsInNavigationStack { - header - } - ScrollView { - VStack(spacing: 18) { - sections + Button("Delete Rule", role: .destructive) { + onDelete?() + } + .accessibilityIdentifier("deleteRuleButton") } - .padding(.horizontal, 20) - .padding(.top, 10) - .padding(.bottom, 24) } - footer - .padding(.horizontal, 20) - .padding(.bottom, 16) } - .foregroundStyle(.white) - .background(Theme.background) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Text(draft.name) + .font(.headline) + .lineLimit(1) + .accessibilityIdentifier("ruleEditorTitle") + } + ToolbarItem(placement: .topBarTrailing) { + Button("Rename", systemImage: "pencil") { + renameText = draft.name + showingRename = true + } + .accessibilityIdentifier("renameButton") + } + if case .edit = mode { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + onCommit(draft) + } + .accessibilityIdentifier("doneButton") + } + } + } + .safeAreaInset(edge: .bottom) { + if mode == .create { + Button { + onCommit(draft) + } label: { + Text("Add Rule") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .padding(.horizontal) + .padding(.bottom, 8) + .accessibilityIdentifier("commitRuleButton") + } + } .alert("Rule Name", isPresented: $showingRename) { TextField("Name", text: $renameText) Button("OK") { @@ -90,256 +94,159 @@ struct RuleEditorView: View { } .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 - adultContentSection + Section { + DatePicker( + "From", + selection: timeBinding($draft.startMinutes), + displayedComponents: .hourAndMinute + ) + .accessibilityIdentifier("fromTimePicker") + DatePicker( + "To", + selection: timeBinding($draft.endMinutes), + displayedComponents: .hourAndMinute + ) + .accessibilityIdentifier("toTimePicker") + } header: { + Text("During this time").textCase(nil) + } + daysSection + Section { + selectedAppsRow + } header: { + Text("Apps are blocked").textCase(nil) + } + toggleSections 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) + Section { + selectedAppsRow + } header: { + Text("When I use").textCase(nil) + } + Section { + budgetRow( + value: "\(draft.dailyLimitMinutes)m", + stepperID: "dailyLimitStepper", + onIncrement: { draft.dailyLimitMinutes = min(240, draft.dailyLimitMinutes + 15) }, + onDecrement: { draft.dailyLimitMinutes = max(15, draft.dailyLimitMinutes - 15) } + ) + } header: { + Text("For this long").textCase(nil) + } + daysSection blockUntilSection - hardModeSection - adultContentSection + toggleSections 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) + Section { + selectedAppsRow + } header: { + Text("When I open").textCase(nil) + } + Section { + budgetRow( + value: "\(draft.maxOpens) opens", + stepperID: "maxOpensStepper", + onIncrement: { draft.maxOpens = min(50, draft.maxOpens + 1) }, + onDecrement: { draft.maxOpens = max(1, draft.maxOpens - 1) } + ) + } header: { + Text("More than").textCase(nil) + } + daysSection blockUntilSection - hardModeSection - adultContentSection + toggleSections } } - 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") + private var daysSection: some View { + Section { + DayOfWeekPicker(days: $draft.days) + } header: { + HStack { + Text("On these days").textCase(nil) + Spacer() + Text(draft.days.summary).textCase(nil) } - .background(Theme.surface, in: RoundedRectangle(cornerRadius: 18)) } } - private func timeRow(label: String, minutes: Binding, 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) + private var blockUntilSection: some View { + Section { + LabeledContent("Until", value: "Tomorrow") + } header: { + Text("Then block app").textCase(nil) } - .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)) + @ViewBuilder + private var toggleSections: some View { + Section { + HStack { + Text("Hard Mode") + Spacer() + Toggle("", isOn: $draft.hardMode) + .labelsHidden() + .accessibilityIdentifier("hardModeToggle") } - .buttonStyle(.plain) - .accessibilityIdentifier("selectedAppsRow") + } footer: { + Text("No unblocks allowed while the rule is blocking.") + } + Section { + HStack { + Text("Block Adult Content") + Spacer() + Toggle("", isOn: $draft.blockAdultContent) + .labelsHidden() + .accessibilityIdentifier("adultContentToggle") + } + } footer: { + Text("Filter adult websites while this rule is active.") } } - private func budgetSection( - title: String, + private var selectedAppsRow: some View { + Button { + showingAppPicker = true + } label: { + HStack { + Text("Selected Apps") + .foregroundStyle(Color.primary) + Spacer() + Text(draft.selectionCount == 1 ? "1 App" : "\(draft.selectionCount) Apps") + .foregroundStyle(Color.secondary) + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + } + } + .accessibilityIdentifier("selectedAppsRow") + } + + private func budgetRow( 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) - } + Text("Daily") Spacer() Text(value) - .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.secondary) .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 var adultContentSection: some View { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text("Block Adult Content") - .font(.system(size: 15, weight: .semibold)) - Text("Filter adult websites while this rule is active") - .font(.system(size: 12)) - .foregroundStyle(Theme.textTertiary) - } - Spacer() - Toggle("", isOn: $draft.blockAdultContent) - .labelsHidden() - .tint(Theme.accent) - .accessibilityIdentifier("adultContentToggle") - } - .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) -> Binding { diff --git a/Severed/Views/Theme.swift b/Severed/Views/Theme.swift deleted file mode 100644 index 4d25cd1..0000000 --- a/Severed/Views/Theme.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// 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) - ) - } -} diff --git a/SeveredUITests/RuleCreationUITests.swift b/SeveredUITests/RuleCreationUITests.swift index e57179e..af99776 100644 --- a/SeveredUITests/RuleCreationUITests.swift +++ b/SeveredUITests/RuleCreationUITests.swift @@ -23,7 +23,7 @@ final class RuleCreationUITests: XCTestCase { XCTAssertTrue(app.staticTexts["During this time"].exists) // Hold to commit saves the rule and returns home. - app.buttons["holdToCommitButton"].waitToAppear().press(forDuration: 2.0) + app.buttons["commitRuleButton"].waitToAppear().tap() app.buttons["ruleCard-In the Zone"].waitToAppear() XCTAssertFalse(app.element("emptyRulesCard").exists) } @@ -35,7 +35,7 @@ final class RuleCreationUITests: XCTestCase { app.buttons["preset-work-time"].waitToAppear().tap() XCTAssertEqual(app.staticTexts["ruleEditorTitle"].waitToAppear().label, "Work Time") - app.buttons["holdToCommitButton"].waitToAppear().press(forDuration: 2.0) + app.buttons["commitRuleButton"].waitToAppear().tap() app.buttons["ruleCard-Work Time"].waitToAppear() } @@ -53,7 +53,7 @@ final class RuleCreationUITests: XCTestCase { app.buttons["OK"].tap() XCTAssertEqual(app.staticTexts["ruleEditorTitle"].label, "My Focus") - app.buttons["holdToCommitButton"].waitToAppear().press(forDuration: 2.0) + app.buttons["commitRuleButton"].waitToAppear().tap() app.buttons["ruleCard-My Focus"].waitToAppear() } @@ -81,7 +81,7 @@ final class RuleCreationUITests: XCTestCase { app.steppers["dailyLimitStepper"].buttons["Increment"].tap() XCTAssertEqual(app.staticTexts["dailyLimitStepperValue"].label, "60m") - app.buttons["holdToCommitButton"].waitToAppear().press(forDuration: 2.0) + app.buttons["commitRuleButton"].waitToAppear().tap() app.buttons["ruleCard-Time Keeper"].waitToAppear() } @@ -91,7 +91,7 @@ final class RuleCreationUITests: XCTestCase { app.buttons["ruleKind-schedule"].waitToAppear().tap() app.switches["adultContentToggle"].waitToAppear().tap() - app.buttons["holdToCommitButton"].waitToAppear().press(forDuration: 2.0) + app.buttons["commitRuleButton"].waitToAppear().tap() app.buttons["ruleCard-In the Zone"].waitToAppear().tap() let row = app.element("detailRow-Adult websites").waitToAppear() diff --git a/docs/RULES_FEATURE_SPEC.md b/docs/RULES_FEATURE_SPEC.md index 7090ccf..c1098d0 100644 --- a/docs/RULES_FEATURE_SPEC.md +++ b/docs/RULES_FEATURE_SPEC.md @@ -329,3 +329,23 @@ enum SelectionMode: String, Codable { case block, allowOnly } - Onboarding flow, paywall ("You know Opal works. Make it permanent."), Home tab gem/score UI, Timer tab (one-off focus sessions, "Leave Early?" friction screen), notification nudges ("Complete Your Setup"). + +--- + +## 6. Native UI re-skin (current presentation) + +Severed has since replaced the Opal-style custom presentation with the bare +iOS design language, keeping the backend (models, logic, services), the +flows, and the accessibility identifiers intact. Sections 1–5 remain as the +reference for *what* the feature does; presentation now maps as follows: + +| Spec element | Native presentation | +|---|---| +| Apps home | `NavigationStack` + `List`; "Blocked Apps" and "Rules" sections; **rules are list rows** (kind icon, name, block summary, trailing live status — green when active); "+" toolbar button | +| Rule detail | Sheet with inline nav title (name + "Schedule, 6h left" caption), `LabeledContent` rows, "Edit Rule" row pushes the editor; hard-locked rules show a lock row instead | +| New Rule | `List` with a "Rule Type" section and preset sections as plain rows; editor pushed via `navigationDestination(item:)` | +| Rule editor | Native `Form`: `DatePicker` rows, day-circle row with the summary in the section header, toggle rows with footers, stepper rows. Create commits with a prominent **"Add Rule"** button (replaces Hold to Commit); edit uses toolbar **Done** plus red Disable/Delete rows | +| Onboarding / app picker | System styling, `.borderedProminent` buttons, default color scheme (no forced dark, default accent) | + +Dropped custom components: `Theme`, `HoldToCommitButton`, `RuleCardView`, +icon-pair/circle-button chrome.