refactor: re-skin UI with native iOS components
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 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -53,8 +53,6 @@ struct SeveredApp: App {
|
|||||||
RootView()
|
RootView()
|
||||||
.environment(authorization)
|
.environment(authorization)
|
||||||
.environment(enforcer)
|
.environment(enforcer)
|
||||||
.preferredColorScheme(.dark)
|
|
||||||
.tint(Theme.accent)
|
|
||||||
}
|
}
|
||||||
.modelContainer(container)
|
.modelContainer(container)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
import SwiftData
|
import SwiftData
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// The app's home screen, modeled on the reference "Apps" tab: what's blocked
|
/// Home screen using plain iOS components: a List with a "Blocked Apps"
|
||||||
/// right now, then the Rules carousel with "+ New".
|
/// 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 {
|
struct AppsHomeView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
@Environment(RuleEnforcer.self) private var enforcer
|
@Environment(RuleEnforcer.self) private var enforcer
|
||||||
@@ -21,27 +22,23 @@ struct AppsHomeView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
TimelineView(.periodic(from: .now, by: 30)) { timeline in
|
TimelineView(.periodic(from: .now, by: 30)) { timeline in
|
||||||
ScrollView {
|
ruleList(now: timeline.date)
|
||||||
VStack(alignment: .leading, spacing: 28) {
|
}
|
||||||
blockedAppsSection(now: timeline.date)
|
.navigationTitle("Apps")
|
||||||
rulesSection(now: timeline.date)
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("New Rule", systemImage: "plus") {
|
||||||
|
showingNewRule = true
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.accessibilityIdentifier("newRuleButton")
|
||||||
.padding(.top, 8)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Theme.background)
|
|
||||||
.navigationTitle("Apps")
|
|
||||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
|
||||||
}
|
}
|
||||||
.sheet(item: $detailRule) { rule in
|
.sheet(item: $detailRule) { rule in
|
||||||
RuleDetailSheet(rule: rule)
|
RuleDetailSheet(rule: rule)
|
||||||
.presentationDetents([.large])
|
|
||||||
.presentationDragIndicator(.visible)
|
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingNewRule) {
|
.sheet(isPresented: $showingNewRule) {
|
||||||
NewRuleSheet()
|
NewRuleSheet()
|
||||||
.presentationDragIndicator(.visible)
|
|
||||||
}
|
}
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
"Unblock \(unblockCandidate?.name ?? "")?",
|
"Unblock \(unblockCandidate?.name ?? "")?",
|
||||||
@@ -74,30 +71,34 @@ struct AppsHomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Blocked Apps
|
private func ruleList(now: Date) -> some View {
|
||||||
|
List {
|
||||||
@ViewBuilder
|
blockedSection(now: now)
|
||||||
private func blockedAppsSection(now: Date) -> some View {
|
rulesSection(now: now)
|
||||||
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 {
|
// 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 {
|
Button {
|
||||||
if RulePolicy.canUnblock(rule, at: now) {
|
if RulePolicy.canUnblock(rule, at: now) {
|
||||||
unblockCandidate = rule
|
unblockCandidate = rule
|
||||||
@@ -105,75 +106,86 @@ struct AppsHomeView: View {
|
|||||||
hardModeBlockedAttempt = true
|
hardModeBlockedAttempt = true
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
VStack(spacing: 6) {
|
HStack {
|
||||||
Image(systemName: rule.hardMode ? "lock.fill" : "lock.open.fill")
|
Image(systemName: rule.hardMode ? "lock.fill" : "lock.open.fill")
|
||||||
.font(.system(size: 20, weight: .semibold))
|
.foregroundStyle(rule.hardMode ? .red : .secondary)
|
||||||
.foregroundStyle(.white.opacity(0.85))
|
.frame(width: 28)
|
||||||
.frame(width: 58, height: 58)
|
Text(rule.name)
|
||||||
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 14))
|
.foregroundStyle(Color.primary)
|
||||||
.overlay(
|
Spacer()
|
||||||
RoundedRectangle(cornerRadius: 14)
|
|
||||||
.strokeBorder(Theme.accent.opacity(0.6), lineWidth: 1)
|
|
||||||
)
|
|
||||||
Text("Unblock")
|
Text("Unblock")
|
||||||
.font(.system(size: 12))
|
.foregroundStyle(.tint)
|
||||||
.foregroundStyle(Theme.textSecondary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityIdentifier("blockedTile-\(rule.name)")
|
.accessibilityIdentifier("blockedTile-\(rule.name)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Rules
|
// MARK: - Rules
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
private func rulesSection(now: Date) -> some View {
|
private func rulesSection(now: Date) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
Section {
|
||||||
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 {
|
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 {
|
} else {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ForEach(rules) { rule in
|
||||||
HStack(spacing: 14) {
|
ruleRow(for: rule, now: now)
|
||||||
ForEach(rules) { rule in
|
|
||||||
Button {
|
|
||||||
detailRule = rule
|
|
||||||
} label: {
|
|
||||||
RuleCardView(rule: rule, now: now)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityIdentifier("ruleCard-\(rule.name)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Rules").textCase(nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var emptyRulesCard: some View {
|
private func ruleRow(for rule: BlockingRule, now: Date) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
let status = rule.status(at: now)
|
||||||
Text("No rules yet")
|
return Button {
|
||||||
.font(.system(size: 16, weight: .semibold))
|
detailRule = rule
|
||||||
Text("Create a rule to block distracting apps on a schedule.")
|
} label: {
|
||||||
.font(.system(size: 13))
|
HStack {
|
||||||
.foregroundStyle(Theme.textSecondary)
|
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
|
// MARK: - Enforcement
|
||||||
@@ -181,8 +193,9 @@ struct AppsHomeView: View {
|
|||||||
/// Changes whenever any rule's blocking-relevant state changes.
|
/// Changes whenever any rule's blocking-relevant state changes.
|
||||||
private var ruleChangeToken: String {
|
private var ruleChangeToken: String {
|
||||||
rules.map {
|
rules.map {
|
||||||
"\($0.id)|\($0.isEnabled)|\($0.hardMode)|\($0.startMinutes)|\($0.endMinutes)|"
|
"\($0.id)|\($0.isEnabled)|\($0.hardMode)|\($0.blockAdultContent)|"
|
||||||
+ "\($0.dayNumbers)|\($0.pausedUntil?.timeIntervalSince1970 ?? 0)"
|
+ "\($0.startMinutes)|\($0.endMinutes)|\($0.dayNumbers)|"
|
||||||
|
+ "\($0.selectionCount)|\($0.pausedUntil?.timeIntervalSince1970 ?? 0)"
|
||||||
}
|
}
|
||||||
.joined(separator: ",")
|
.joined(separator: ",")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,29 +5,19 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// "On these days:" — seven circular toggles (S M T W T F S) with a
|
/// Seven circular day toggles (S M T W T F S) using system colors, meant to
|
||||||
/// human-readable summary, as in the reference rule editors.
|
/// sit inside a Form/List row. The day-set summary is shown by the enclosing
|
||||||
|
/// section header.
|
||||||
struct DayOfWeekPicker: View {
|
struct DayOfWeekPicker: View {
|
||||||
@Binding var days: Set<Weekday>
|
@Binding var days: Set<Weekday>
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
HStack(spacing: 8) {
|
||||||
HStack {
|
ForEach(Weekday.displayOrder, id: \.self) { day in
|
||||||
Text("On these days:")
|
dayToggle(day)
|
||||||
.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)
|
.padding(.vertical, 4)
|
||||||
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func dayToggle(_ day: Weekday) -> some View {
|
private func dayToggle(_ day: Weekday) -> some View {
|
||||||
@@ -40,13 +30,16 @@ struct DayOfWeekPicker: View {
|
|||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text(day.shortLabel)
|
Text(day.shortLabel)
|
||||||
.font(.system(size: 15, weight: .semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
.foregroundStyle(isOn ? .black : Theme.textSecondary)
|
.foregroundStyle(isOn ? Color.white : .secondary)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.aspectRatio(1, contentMode: .fit)
|
.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)")
|
.accessibilityIdentifier("dayToggle-\(day.rawValue)")
|
||||||
.accessibilityLabel(day.abbreviation)
|
.accessibilityLabel(day.abbreviation)
|
||||||
.accessibilityAddTraits(isOn ? .isSelected : [])
|
.accessibilityAddTraits(isOn ? .isSelected : [])
|
||||||
@@ -55,7 +48,15 @@ struct DayOfWeekPicker: View {
|
|||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
@Previewable @State var days = Weekday.weekdays
|
@Previewable @State var days = Weekday.weekdays
|
||||||
DayOfWeekPicker(days: $days)
|
Form {
|
||||||
.padding()
|
Section {
|
||||||
.background(Theme.background)
|
DayOfWeekPicker(days: $days)
|
||||||
|
} header: {
|
||||||
|
HStack {
|
||||||
|
Text("On these days").textCase(nil)
|
||||||
|
Spacer()
|
||||||
|
Text(days.summary).textCase(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -5,9 +5,9 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Two-step onboarding: a welcome screen, then the Screen Time permission
|
/// Two-step onboarding using system styling: a welcome screen, then the
|
||||||
/// request. Onboarding only completes once authorization is approved — the
|
/// Screen Time permission request. Onboarding only completes once
|
||||||
/// app cannot block anything without it.
|
/// authorization is approved — the app cannot block anything without it.
|
||||||
struct OnboardingView: View {
|
struct OnboardingView: View {
|
||||||
@Environment(ScreenTimeAuthorization.self) private var authorization
|
@Environment(ScreenTimeAuthorization.self) private var authorization
|
||||||
@Environment(\.openURL) private var openURL
|
@Environment(\.openURL) private var openURL
|
||||||
@@ -31,23 +31,18 @@ struct OnboardingView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
footer
|
footer
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 28)
|
.padding()
|
||||||
.padding(.bottom, 24)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.background(Theme.background)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var welcome: some View {
|
private var welcome: some View {
|
||||||
VStack(spacing: 18) {
|
VStack(spacing: 16) {
|
||||||
Image(systemName: "scissors")
|
Image(systemName: "scissors")
|
||||||
.font(.system(size: 56, weight: .semibold))
|
.font(.system(size: 56))
|
||||||
.foregroundStyle(Theme.accent)
|
.foregroundStyle(.tint)
|
||||||
Text("Severed")
|
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.")
|
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(.secondary)
|
||||||
.foregroundStyle(Theme.textSecondary)
|
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,10 +50,10 @@ struct OnboardingView: View {
|
|||||||
private var permission: some View {
|
private var permission: some View {
|
||||||
VStack(spacing: 24) {
|
VStack(spacing: 24) {
|
||||||
Image(systemName: "hourglass")
|
Image(systemName: "hourglass")
|
||||||
.font(.system(size: 48, weight: .semibold))
|
.font(.system(size: 48))
|
||||||
.foregroundStyle(Theme.accent)
|
.foregroundStyle(.tint)
|
||||||
Text("Allow Screen Time Access")
|
Text("Allow Screen Time Access")
|
||||||
.font(.system(size: 28, weight: .bold))
|
.font(.title.bold())
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
bullet("shield.fill", "Severed uses Apple's Screen Time framework to block the apps you choose.")
|
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 {
|
if authorization.status == .denied || authorization.lastRequestFailed {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
Text("Screen Time access was declined. Severed can't block apps without it.")
|
Text("Screen Time access was declined. Severed can't block apps without it.")
|
||||||
.font(.system(size: 13))
|
.font(.footnote)
|
||||||
.foregroundStyle(Theme.destructive)
|
.foregroundStyle(.red)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.accessibilityIdentifier("permissionDeniedLabel")
|
.accessibilityIdentifier("permissionDeniedLabel")
|
||||||
Button("Open Settings") {
|
Button("Open Settings") {
|
||||||
@@ -77,8 +72,6 @@ struct OnboardingView: View {
|
|||||||
openURL(url)
|
openURL(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.system(size: 15, weight: .semibold))
|
|
||||||
.foregroundStyle(Theme.accent)
|
|
||||||
.accessibilityIdentifier("openSettingsButton")
|
.accessibilityIdentifier("openSettingsButton")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,12 +81,11 @@ struct OnboardingView: View {
|
|||||||
private func bullet(_ systemImage: String, _ text: String) -> some View {
|
private func bullet(_ systemImage: String, _ text: String) -> some View {
|
||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
Image(systemName: systemImage)
|
Image(systemName: systemImage)
|
||||||
.font(.system(size: 15, weight: .semibold))
|
.foregroundStyle(.tint)
|
||||||
.foregroundStyle(Theme.accent)
|
.frame(width: 24)
|
||||||
.frame(width: 22)
|
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.system(size: 15))
|
.font(.subheadline)
|
||||||
.foregroundStyle(Theme.textSecondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,13 +119,10 @@ struct OnboardingView: View {
|
|||||||
) -> some View {
|
) -> some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.system(size: 17, weight: .semibold))
|
|
||||||
.foregroundStyle(.black)
|
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.frame(height: 54)
|
|
||||||
.background(.white, in: RoundedRectangle(cornerRadius: 27))
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.borderedProminent)
|
||||||
|
.controlSize(.large)
|
||||||
.accessibilityIdentifier(identifier)
|
.accessibilityIdentifier(identifier)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,8 @@
|
|||||||
import FamilyControls
|
import FamilyControls
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// App/category/website selection for a rule. Wraps Apple's
|
/// App/category/website selection wrapping Apple's `FamilyActivityPicker`,
|
||||||
/// `FamilyActivityPicker` (the system-sanctioned way to choose apps without
|
/// presented as a plain sheet with a segmented Block / Allow Only control.
|
||||||
/// seeing their identities) and adds the reference app's Block / Allow Only
|
|
||||||
/// mode switch.
|
|
||||||
struct AppSelectionSheet: View {
|
struct AppSelectionSheet: View {
|
||||||
@Binding var draft: RuleDraft
|
@Binding var draft: RuleDraft
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@@ -26,58 +24,56 @@ struct AppSelectionSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
NavigationStack {
|
||||||
HStack {
|
VStack(spacing: 0) {
|
||||||
CircleIconButton(systemImage: "chevron.left") { dismiss() }
|
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")
|
.accessibilityIdentifier("selectionBackButton")
|
||||||
Spacer()
|
}
|
||||||
CircleIconButton(systemImage: "checkmark", tint: .black) { save() }
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
.background(Theme.accent, in: Circle())
|
Button("Done") {
|
||||||
|
save()
|
||||||
|
}
|
||||||
.accessibilityIdentifier("confirmSelectionButton")
|
.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)
|
.safeAreaInset(edge: .bottom) {
|
||||||
.padding(.horizontal, 20)
|
VStack(spacing: 8) {
|
||||||
.padding(.top, 16)
|
Text(selectionSummary)
|
||||||
.accessibilityIdentifier("selectionModePicker")
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
FamilyActivityPicker(selection: $selection)
|
.accessibilityIdentifier("selectionCountLabel")
|
||||||
.padding(.top, 4)
|
Button {
|
||||||
|
save()
|
||||||
VStack(spacing: 10) {
|
} label: {
|
||||||
Text(selectionSummary)
|
Text("Save")
|
||||||
.font(.system(size: 14))
|
.frame(maxWidth: .infinity)
|
||||||
.foregroundStyle(Theme.textSecondary)
|
}
|
||||||
.accessibilityIdentifier("selectionCountLabel")
|
.buttonStyle(.borderedProminent)
|
||||||
Button {
|
.controlSize(.large)
|
||||||
save()
|
.accessibilityIdentifier("saveSelectionButton")
|
||||||
} 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)
|
.padding(.horizontal)
|
||||||
.accessibilityIdentifier("saveSelectionButton")
|
.padding(.bottom, 8)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
|
||||||
.padding(.bottom, 16)
|
|
||||||
}
|
}
|
||||||
.background(Theme.background)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var selectionSummary: String {
|
private var selectionSummary: String {
|
||||||
|
|||||||
@@ -6,9 +6,8 @@
|
|||||||
import SwiftData
|
import SwiftData
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// "New Rule": the three rule-type cards up top, then the preset gallery.
|
/// "New Rule" as a plain list: a Rule Type section, then the preset sections.
|
||||||
/// Picking either one morphs the sheet into the editor; committing saves the
|
/// Picking either pushes the editor; committing saves and closes the sheet.
|
||||||
/// rule and closes the sheet.
|
|
||||||
struct NewRuleSheet: View {
|
struct NewRuleSheet: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
@@ -16,142 +15,97 @@ struct NewRuleSheet: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
chooser
|
List {
|
||||||
.toolbar(.hidden, for: .navigationBar)
|
Section {
|
||||||
.navigationDestination(item: $pendingDraft) { draft in
|
ForEach(RuleKind.allCases, id: \.self) { kind in
|
||||||
RuleEditorView(
|
kindRow(kind)
|
||||||
mode: .create,
|
}
|
||||||
draft: draft,
|
} header: {
|
||||||
embedsInNavigationStack: true,
|
Text("Rule Type").textCase(nil)
|
||||||
onCommit: { committed in
|
|
||||||
modelContext.insert(committed.makeRule())
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
ForEach(RulePresetSection.all) { section in
|
||||||
.background(Theme.background)
|
Section {
|
||||||
}
|
ForEach(section.presets) { preset in
|
||||||
|
presetRow(preset)
|
||||||
private var chooser: some View {
|
}
|
||||||
VStack(spacing: 0) {
|
} header: {
|
||||||
HStack {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
CircleIconButton(systemImage: "xmark") { dismiss() }
|
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")
|
.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)
|
.navigationDestination(item: $pendingDraft) { draft in
|
||||||
.padding(.bottom, 30)
|
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 {
|
Button {
|
||||||
pendingDraft = RuleDraft(kind: kind)
|
pendingDraft = RuleDraft(kind: kind)
|
||||||
} label: {
|
} label: {
|
||||||
VStack(spacing: 8) {
|
HStack {
|
||||||
Image(systemName: kind.symbolName)
|
Image(systemName: kind.symbolName)
|
||||||
.font(.system(size: 20, weight: .semibold))
|
.foregroundStyle(.tint)
|
||||||
.foregroundStyle(Theme.accent)
|
.frame(width: 28)
|
||||||
.frame(height: 26)
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(kind.displayName)
|
Text(kind.displayName)
|
||||||
.font(.system(size: 14, weight: .semibold))
|
.foregroundStyle(Color.primary)
|
||||||
Text(kind.exampleText)
|
Text(kind.exampleText)
|
||||||
.font(.system(size: 11))
|
.font(.caption)
|
||||||
.foregroundStyle(Theme.textTertiary)
|
.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)")
|
.accessibilityIdentifier("ruleKind-\(kind.rawValue)")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func presetSection(_ section: RulePresetSection) -> some View {
|
private func presetRow(_ preset: RulePreset) -> 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 {
|
Button {
|
||||||
pendingDraft = RuleDraft(preset: preset)
|
pendingDraft = RuleDraft(preset: preset)
|
||||||
} label: {
|
} label: {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
HStack {
|
||||||
HStack {
|
Image(systemName: preset.symbolName)
|
||||||
RuleIconPair(kind: .schedule)
|
.foregroundStyle(.tint)
|
||||||
Spacer()
|
.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()
|
Spacer()
|
||||||
Image(systemName: preset.symbolName)
|
Image(systemName: "plus.circle.fill")
|
||||||
.font(.system(size: 22, weight: .semibold))
|
.foregroundStyle(.tint)
|
||||||
.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)")
|
.accessibilityIdentifier("preset-\(preset.id)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,9 @@
|
|||||||
import SwiftData
|
import SwiftData
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// The card-style rule summary shown when tapping a rule: icon pair, live
|
/// Rule summary presented as a plain sheet: inline title with a live status
|
||||||
/// status, the key facts, and "Edit Rule" — which morphs the sheet into the
|
/// caption, the rule's facts as labeled rows, and "Edit Rule" — which pushes
|
||||||
/// editor, as in the reference app. A hard-locked rule shows a lock notice
|
/// the editor. A hard-locked rule shows a lock notice instead.
|
||||||
/// instead of the edit button.
|
|
||||||
struct RuleDetailSheet: View {
|
struct RuleDetailSheet: View {
|
||||||
let rule: BlockingRule
|
let rule: BlockingRule
|
||||||
|
|
||||||
@@ -19,12 +18,14 @@ struct RuleDetailSheet: View {
|
|||||||
@State private var pendingDeletion = false
|
@State private var pendingDeletion = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TimelineView(.periodic(from: .now, by: 30)) { timeline in
|
NavigationStack {
|
||||||
if isEditing {
|
TimelineView(.periodic(from: .now, by: 30)) { timeline in
|
||||||
|
detailList(now: timeline.date)
|
||||||
|
}
|
||||||
|
.navigationDestination(isPresented: $isEditing) {
|
||||||
RuleEditorView(
|
RuleEditorView(
|
||||||
mode: .edit(isEnabled: rule.isEnabled),
|
mode: .edit(isEnabled: rule.isEnabled),
|
||||||
draft: RuleDraft(rule: rule),
|
draft: RuleDraft(rule: rule),
|
||||||
onBack: { isEditing = false },
|
|
||||||
onCommit: { draft in
|
onCommit: { draft in
|
||||||
draft.apply(to: rule)
|
draft.apply(to: rule)
|
||||||
isEditing = false
|
isEditing = false
|
||||||
@@ -39,11 +40,8 @@ struct RuleDetailSheet: View {
|
|||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
detail(now: timeline.date)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Theme.background)
|
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
if pendingDeletion {
|
if pendingDeletion {
|
||||||
modelContext.delete(rule)
|
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)
|
let status = rule.status(at: now)
|
||||||
return VStack(spacing: 0) {
|
return List {
|
||||||
HStack {
|
Section {
|
||||||
CircleIconButton(systemImage: "xmark") { dismiss() }
|
detailRows
|
||||||
.accessibilityIdentifier("closeDetailButton")
|
|
||||||
Spacer()
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
Section {
|
||||||
.padding(.top, 18)
|
if RulePolicy.canEdit(rule, at: now) {
|
||||||
|
Button {
|
||||||
ScrollView {
|
isEditing = true
|
||||||
VStack(spacing: 24) {
|
} label: {
|
||||||
RuleIconPair(kind: rule.kind, isActive: status.isActive)
|
Label("Edit Rule", systemImage: "pencil")
|
||||||
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
|
.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 {
|
private var detailRows: some View {
|
||||||
VStack(spacing: 0) {
|
switch rule.kind {
|
||||||
switch rule.kind {
|
case .schedule:
|
||||||
case .schedule:
|
row("During this time", rule.schedule.timeRangeLabel)
|
||||||
row("During this time", rule.schedule.timeRangeLabel)
|
row("On these days", rule.days.summary)
|
||||||
divider
|
row(rule.selectionMode.displayName, appCountLabel)
|
||||||
row("On these days", rule.days.summary)
|
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
|
||||||
divider
|
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
|
||||||
row(rule.selectionMode.displayName, appCountLabel)
|
case .timeLimit:
|
||||||
divider
|
row("When I use", appCountLabel)
|
||||||
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
|
row("For this long", "\(rule.dailyLimitMinutes)m daily")
|
||||||
divider
|
row("On these days", rule.days.summary)
|
||||||
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
|
row("Then block until", "Tomorrow")
|
||||||
case .timeLimit:
|
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
|
||||||
row("When I use", appCountLabel)
|
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
|
||||||
divider
|
case .openLimit:
|
||||||
row("For this long", "\(rule.dailyLimitMinutes)m daily")
|
row("When I open", appCountLabel)
|
||||||
divider
|
row("More than", "\(rule.maxOpens) opens daily")
|
||||||
row("On these days", rule.days.summary)
|
row("On these days", rule.days.summary)
|
||||||
divider
|
row("Then block until", "Tomorrow")
|
||||||
row("Then block until", "Tomorrow")
|
row("Adult websites", rule.blockAdultContent ? "Blocked" : "Allowed")
|
||||||
divider
|
row("Unblocks allowed", rule.hardMode ? "No" : "Yes")
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var appCountLabel: String {
|
private var appCountLabel: String {
|
||||||
@@ -134,51 +127,8 @@ struct RuleDetailSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func row(_ label: String, _ value: String) -> some View {
|
private func row(_ label: String, _ value: String) -> some View {
|
||||||
HStack {
|
LabeledContent(label, value: value)
|
||||||
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)
|
.accessibilityElement(children: .combine)
|
||||||
.accessibilityIdentifier("hardModeLockedNotice")
|
.accessibilityIdentifier("detailRow-\(label)")
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,10 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// The rule editor used both for creation (Hold to Commit) and editing
|
/// The rule editor as a plain Form, always pushed inside a NavigationStack
|
||||||
/// (Done / Disable Rule / Delete Rule). Sections adapt to the rule kind,
|
/// (New Rule flow and detail editing both push it). Creation commits with a
|
||||||
/// mirroring the reference app's "In the Zone" and "Time Keeper" editors.
|
/// prominent "Add Rule" button; editing uses a toolbar Done plus red
|
||||||
|
/// Disable/Delete rows.
|
||||||
struct RuleEditorView: View {
|
struct RuleEditorView: View {
|
||||||
enum Mode: Equatable {
|
enum Mode: Equatable {
|
||||||
case create
|
case create
|
||||||
@@ -16,11 +17,6 @@ struct RuleEditorView: View {
|
|||||||
|
|
||||||
let mode: Mode
|
let mode: Mode
|
||||||
@State var draft: RuleDraft
|
@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 onCommit: (RuleDraft) -> Void
|
||||||
var onToggleEnabled: (() -> Void)?
|
var onToggleEnabled: (() -> Void)?
|
||||||
var onDelete: (() -> Void)?
|
var onDelete: (() -> Void)?
|
||||||
@@ -30,54 +26,62 @@ struct RuleEditorView: View {
|
|||||||
@State private var renameText = ""
|
@State private var renameText = ""
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if embedsInNavigationStack {
|
Form {
|
||||||
core
|
sections
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
if case .edit(let isEnabled) = mode {
|
||||||
.toolbarBackground(.hidden, for: .navigationBar)
|
Section {
|
||||||
.toolbar {
|
Button(isEnabled ? "Disable Rule" : "Enable Rule") {
|
||||||
ToolbarItem(placement: .principal) {
|
onToggleEnabled?()
|
||||||
Text(draft.name)
|
|
||||||
.font(.system(size: 18, weight: .semibold))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.lineLimit(1)
|
|
||||||
.accessibilityIdentifier("ruleEditorTitle")
|
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
.foregroundStyle(.red)
|
||||||
Button {
|
.accessibilityIdentifier("toggleEnabledButton")
|
||||||
renameText = draft.name
|
|
||||||
showingRename = true
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "pencil")
|
|
||||||
.font(.system(size: 15, weight: .semibold))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
}
|
|
||||||
.accessibilityIdentifier("renameButton")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
core
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var core: some View {
|
Button("Delete Rule", role: .destructive) {
|
||||||
VStack(spacing: 0) {
|
onDelete?()
|
||||||
if !embedsInNavigationStack {
|
}
|
||||||
header
|
.accessibilityIdentifier("deleteRuleButton")
|
||||||
}
|
|
||||||
ScrollView {
|
|
||||||
VStack(spacing: 18) {
|
|
||||||
sections
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
|
||||||
.padding(.top, 10)
|
|
||||||
.padding(.bottom, 24)
|
|
||||||
}
|
}
|
||||||
footer
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
.padding(.bottom, 16)
|
|
||||||
}
|
}
|
||||||
.foregroundStyle(.white)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.background(Theme.background)
|
.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) {
|
.alert("Rule Name", isPresented: $showingRename) {
|
||||||
TextField("Name", text: $renameText)
|
TextField("Name", text: $renameText)
|
||||||
Button("OK") {
|
Button("OK") {
|
||||||
@@ -90,256 +94,159 @@ struct RuleEditorView: View {
|
|||||||
}
|
}
|
||||||
.sheet(isPresented: $showingAppPicker) {
|
.sheet(isPresented: $showingAppPicker) {
|
||||||
AppSelectionSheet(draft: $draft)
|
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
|
// MARK: - Sections
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var sections: some View {
|
private var sections: some View {
|
||||||
switch draft.kind {
|
switch draft.kind {
|
||||||
case .schedule:
|
case .schedule:
|
||||||
timeWindowSection
|
Section {
|
||||||
DayOfWeekPicker(days: $draft.days)
|
DatePicker(
|
||||||
appsSection(header: "Apps are blocked")
|
"From",
|
||||||
hardModeSection
|
selection: timeBinding($draft.startMinutes),
|
||||||
adultContentSection
|
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:
|
case .timeLimit:
|
||||||
appsSection(header: "When I use")
|
Section {
|
||||||
budgetSection(
|
selectedAppsRow
|
||||||
title: "For this long",
|
} header: {
|
||||||
value: "\(draft.dailyLimitMinutes)m",
|
Text("When I use").textCase(nil)
|
||||||
stepperID: "dailyLimitStepper",
|
}
|
||||||
onIncrement: { draft.dailyLimitMinutes = min(240, draft.dailyLimitMinutes + 15) },
|
Section {
|
||||||
onDecrement: { draft.dailyLimitMinutes = max(15, draft.dailyLimitMinutes - 15) }
|
budgetRow(
|
||||||
)
|
value: "\(draft.dailyLimitMinutes)m",
|
||||||
DayOfWeekPicker(days: $draft.days)
|
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
|
blockUntilSection
|
||||||
hardModeSection
|
toggleSections
|
||||||
adultContentSection
|
|
||||||
case .openLimit:
|
case .openLimit:
|
||||||
appsSection(header: "When I open")
|
Section {
|
||||||
budgetSection(
|
selectedAppsRow
|
||||||
title: "More than",
|
} header: {
|
||||||
value: "\(draft.maxOpens) opens",
|
Text("When I open").textCase(nil)
|
||||||
stepperID: "maxOpensStepper",
|
}
|
||||||
onIncrement: { draft.maxOpens = min(50, draft.maxOpens + 1) },
|
Section {
|
||||||
onDecrement: { draft.maxOpens = max(1, draft.maxOpens - 1) }
|
budgetRow(
|
||||||
)
|
value: "\(draft.maxOpens) opens",
|
||||||
DayOfWeekPicker(days: $draft.days)
|
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
|
blockUntilSection
|
||||||
hardModeSection
|
toggleSections
|
||||||
adultContentSection
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var timeWindowSection: some View {
|
private var daysSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
Section {
|
||||||
sectionHeader(systemImage: "calendar", title: "During this time")
|
DayOfWeekPicker(days: $draft.days)
|
||||||
VStack(spacing: 0) {
|
} header: {
|
||||||
timeRow(label: "From", minutes: $draft.startMinutes, identifier: "fromTimePicker")
|
HStack {
|
||||||
Divider().background(.white.opacity(0.06)).padding(.leading, 16)
|
Text("On these days").textCase(nil)
|
||||||
timeRow(label: "To", minutes: $draft.endMinutes, identifier: "toTimePicker")
|
Spacer()
|
||||||
|
Text(draft.days.summary).textCase(nil)
|
||||||
}
|
}
|
||||||
.background(Theme.surface, in: RoundedRectangle(cornerRadius: 18))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func timeRow(label: String, minutes: Binding<Int>, identifier: String) -> some View {
|
private var blockUntilSection: some View {
|
||||||
HStack {
|
Section {
|
||||||
Text(label)
|
LabeledContent("Until", value: "Tomorrow")
|
||||||
.font(.system(size: 15, weight: .medium))
|
} header: {
|
||||||
Spacer()
|
Text("Then block app").textCase(nil)
|
||||||
DatePicker("", selection: timeBinding(minutes), displayedComponents: .hourAndMinute)
|
|
||||||
.labelsHidden()
|
|
||||||
.colorScheme(.dark)
|
|
||||||
.accessibilityIdentifier(identifier)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func appsSection(header: String) -> some View {
|
@ViewBuilder
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
private var toggleSections: some View {
|
||||||
sectionHeader(systemImage: "shield.fill", title: header)
|
Section {
|
||||||
Button {
|
HStack {
|
||||||
showingAppPicker = true
|
Text("Hard Mode")
|
||||||
} label: {
|
Spacer()
|
||||||
HStack {
|
Toggle("", isOn: $draft.hardMode)
|
||||||
Text("Selected Apps")
|
.labelsHidden()
|
||||||
.font(.system(size: 15, weight: .medium))
|
.accessibilityIdentifier("hardModeToggle")
|
||||||
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)
|
} footer: {
|
||||||
.accessibilityIdentifier("selectedAppsRow")
|
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(
|
private var selectedAppsRow: some View {
|
||||||
title: String,
|
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,
|
value: String,
|
||||||
stepperID: String,
|
stepperID: String,
|
||||||
onIncrement: @escaping () -> Void,
|
onIncrement: @escaping () -> Void,
|
||||||
onDecrement: @escaping () -> Void
|
onDecrement: @escaping () -> Void
|
||||||
) -> some View {
|
) -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
Text("Daily")
|
||||||
Text(title)
|
|
||||||
.font(.system(size: 15, weight: .medium))
|
|
||||||
Text("Daily")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundStyle(Theme.textTertiary)
|
|
||||||
}
|
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(value)
|
Text(value)
|
||||||
.font(.system(size: 15, weight: .semibold))
|
.foregroundStyle(.secondary)
|
||||||
.accessibilityIdentifier("\(stepperID)Value")
|
.accessibilityIdentifier("\(stepperID)Value")
|
||||||
Stepper("", onIncrement: onIncrement, onDecrement: onDecrement)
|
Stepper("", onIncrement: onIncrement, onDecrement: onDecrement)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.accessibilityIdentifier(stepperID)
|
.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<Int>) -> Binding<Date> {
|
private func timeBinding(_ minutes: Binding<Int>) -> Binding<Date> {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -23,7 +23,7 @@ final class RuleCreationUITests: XCTestCase {
|
|||||||
XCTAssertTrue(app.staticTexts["During this time"].exists)
|
XCTAssertTrue(app.staticTexts["During this time"].exists)
|
||||||
|
|
||||||
// Hold to commit saves the rule and returns home.
|
// 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()
|
app.buttons["ruleCard-In the Zone"].waitToAppear()
|
||||||
XCTAssertFalse(app.element("emptyRulesCard").exists)
|
XCTAssertFalse(app.element("emptyRulesCard").exists)
|
||||||
}
|
}
|
||||||
@@ -35,7 +35,7 @@ final class RuleCreationUITests: XCTestCase {
|
|||||||
app.buttons["preset-work-time"].waitToAppear().tap()
|
app.buttons["preset-work-time"].waitToAppear().tap()
|
||||||
XCTAssertEqual(app.staticTexts["ruleEditorTitle"].waitToAppear().label, "Work Time")
|
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()
|
app.buttons["ruleCard-Work Time"].waitToAppear()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ final class RuleCreationUITests: XCTestCase {
|
|||||||
app.buttons["OK"].tap()
|
app.buttons["OK"].tap()
|
||||||
|
|
||||||
XCTAssertEqual(app.staticTexts["ruleEditorTitle"].label, "My Focus")
|
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()
|
app.buttons["ruleCard-My Focus"].waitToAppear()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ final class RuleCreationUITests: XCTestCase {
|
|||||||
app.steppers["dailyLimitStepper"].buttons["Increment"].tap()
|
app.steppers["dailyLimitStepper"].buttons["Increment"].tap()
|
||||||
XCTAssertEqual(app.staticTexts["dailyLimitStepperValue"].label, "60m")
|
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()
|
app.buttons["ruleCard-Time Keeper"].waitToAppear()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ final class RuleCreationUITests: XCTestCase {
|
|||||||
app.buttons["ruleKind-schedule"].waitToAppear().tap()
|
app.buttons["ruleKind-schedule"].waitToAppear().tap()
|
||||||
|
|
||||||
app.switches["adultContentToggle"].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()
|
app.buttons["ruleCard-In the Zone"].waitToAppear().tap()
|
||||||
let row = app.element("detailRow-Adult websites").waitToAppear()
|
let row = app.element("detailRow-Adult websites").waitToAppear()
|
||||||
|
|||||||
@@ -329,3 +329,23 @@ enum SelectionMode: String, Codable { case block, allowOnly }
|
|||||||
- Onboarding flow, paywall ("You know Opal works. Make it permanent."),
|
- Onboarding flow, paywall ("You know Opal works. Make it permanent."),
|
||||||
Home tab gem/score UI, Timer tab (one-off focus sessions, "Leave Early?"
|
Home tab gem/score UI, Timer tab (one-off focus sessions, "Leave Early?"
|
||||||
friction screen), notification nudges ("Complete Your Setup").
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user