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
This commit is contained in:
@@ -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: ",")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user