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,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)")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user