- ObservableObject 是类型能力,@StateObject 是存储方式
@StateObject 保证只初始化一次,普通 var 或 @ObservedObject 可能被反复重建
只有 class 才能用 ObservableObject,struct 不行
视图内部自己管理的数据源 → 必须用 @StateObject
view 里面可以用@ObservedObject 、@StateObject。
@StateObject 用来创建管理 对象,比如ObservableObject 类型对象;@ObservedObject 是用来外部传递的
- 总结
ObservableObject 是一个协议,用来让一个类能被 SwiftUI 监听数据变化。
@StateObject 是一个属性包装器,用来创建并持有一个遵循 ObservableObject 的实例,保证它只初始化一次、不会被视图重建销毁。
@StateObject:自己创建对象 → 管生命周期
@ObservedObject:外部传入对象 → 不管生命周期
@Published —— 发布属性只要这个值改变,所有用到它的 UI 自动刷新,相当于「广播」:一变全变
@MainActor —— 主线程安全,强制所有 UI 操作在主线程执行,防止崩溃,SwiftUI 现代项目必备:可以用在vm类,观察类,某个方法,以及闭包/异步任务,
凡是跟 UI 有关的:View、ViewModel、ObservableObject,都加上 @MainActor。用来替代 DispatchQueue.main.async;
@EnvironmentObject - 全局变量,可以跨层级传递数据,通常在 App 结构或 ContentView 最外层 注入(.environmentObject(xxxx)),在任意子视图使用 @EnvironmentObject 获取
@main
struct MyApp: App {
// 创建实例
let userData = UserData()
var body: some Scene {
WindowGroup {
ContentView()
// 注入环境
.environmentObject(userData)
}
}
}
struct ContentView: View {
// 直接获取,不需要传参
@EnvironmentObject var userData: UserData
var body: some View {
VStack {
Text("Name: \(userData.name)")
Text("Age: \(userData.age)")
Button("Change Name") {
userData.name = "Jerry"
}
}
}
}
-
@Environment(.dismiss) private var dismiss
你用 @Environment(.xxx) 就是去环境里取东西。从系统环境里,拿一个 “关闭当前页面” 的功能,存到 dismiss 这个变量里
-
异步任务:.task 页面显示时自动加载数据,支持 async/await,页面消失自动取消任务
import SwiftUI
@MainActor
final class SelectExamIntentViewModel: ObservableObject {
@Published var selectedExamID: String?
@Published var selectedSubjectID: String?
@Published var selectedSubjectIDs: [String] = []
@Published var sections: [ExamIntentSection] = []
@Published var showSubjectEditor = false
@Published var title = ""
@Published var subtitle = ""
@Published var categoryId = ""
@Published var secondCategoryId = ""
@Published var boxId = ""
@Published var techId = ""
@Published var isLoadingOptions = false
@Published var isLoading = false
@Published var errorMessage: String?
@Published var infoMessage: String?
@Published var showAdvancedEditor = false
private let store: ExamContextStore
private let service: any ExamContextServiceProtocol
private var hasLoadedOptions = false
private var persistedContexts: [ExerciseContext] = []
init(store: ExamContextStore, service: any ExamContextServiceProtocol) {
self.store = store
self.service = service
applyExistingContexts(store.contexts)
}
var selectedExam: ExamIntentOption? {
sections.flatMap(\.items).first(where: { $0.id == selectedExamID })
}
var examOptions: [ExamIntentOption] {
sections.flatMap(\.items)
}
var allSubjects: [ExamSubjectOption] {
selectedExam?.subjects ?? []
}
var selectedSubject: ExamSubjectOption? {
allSubjects.first(where: { $0.id == selectedSubjectID })
}
var managedSubjects: [ExamSubjectOption] {
selectedSubjectIDs.compactMap { subjectID in
allSubjects.first(where: { $0.id == subjectID })
}
}
var selectedContextPreview: ExerciseContext? {
guard
let selectedExam,
let selectedSubject
else {
return nil
}
return selectedSubject.context(exam: selectedExam)
}
func loadOptionsIfNeeded() async {
guard !hasLoadedOptions else { return }
hasLoadedOptions = true
isLoadingOptions = true
defer {
isLoadingOptions = false
}
do {
let loadedSections = try await service.fetchExamIntentSections()
sections = loadedSections.isEmpty ? ExamIntentSection.mockSections : loadedSections
syncSelectionFromCurrentFields()
} catch {
sections = ExamIntentSection.mockSections
errorMessage = "考试分类加载失败,已切换到内置列表:\(error.localizedDescription)"
syncSelectionFromCurrentFields()
}
}
func selectExam(_ option: ExamIntentOption) {
selectedExamID = option.id
title = option.title
secondCategoryId = String(option.secondCategoryId)
syncSelectionFromCurrentFields()
}
func chooseExamAndEditSubjects(_ option: ExamIntentOption) {
selectExam(option)
showSubjectEditor = true
}
func toggleManagedSubject(_ subject: ExamSubjectOption) {
if let index = selectedSubjectIDs.firstIndex(of: subject.id) {
selectedSubjectIDs.remove(at: index)
if selectedSubjectID == subject.id {
selectedSubjectID = selectedSubjectIDs.last
}
} else {
selectedSubjectIDs.append(subject.id)
selectedSubjectID = subject.id
}
if let activeSubject = selectedSubject {
applySubject(activeSubject)
} else {
clearSubjectSelection()
}
}
func saveSelection() async {
guard let selectedExam else {
errorMessage = "请先选择考试"
return
}
guard !managedSubjects.isEmpty else {
errorMessage = "请至少选择一个科目"
return
}
let contexts = managedSubjects.map { $0.context(exam: selectedExam) }
let primaryContext = selectedSubject?.context(exam: selectedExam) ?? contexts.first
isLoading = true
errorMessage = nil
infoMessage = nil
store.save(contexts, primary: primaryContext)
do {
try await service.saveSelectedCategory(secondCategoryId: selectedExam.secondCategoryId)
infoMessage = "考试意向已同步,已保存 \(managedSubjects.count) 个科目"
} catch {
infoMessage = "已保存本地考试意向,服务端同步失败:\(error.localizedDescription)"
}
isLoading = false
store.dismissSelection()
}
private func applyExistingContexts(_ contexts: [ExerciseContext]) {
persistedContexts = contexts
guard let primaryContext = contexts.first else { return }
title = primaryContext.groupName ?? primaryContext.categoryName ?? ""
subtitle = primaryContext.categoryName ?? ""
categoryId = primaryContext.categoryId.map(String.init) ?? ""
secondCategoryId = String(primaryContext.secondCategoryId)
boxId = primaryContext.boxId.map(String.init) ?? ""
techId = primaryContext.techId.map(String.init) ?? ""
}
private func syncSelectionFromCurrentFields() {
if let matchedExam = examOptions.first(where: { String($0.secondCategoryId) == secondCategoryId }) {
selectedExamID = matchedExam.id
let matchedSubjectIDs = matchedExam.subjects.compactMap { subject in
persistedContexts.contains(where: {
$0.secondCategoryId == matchedExam.secondCategoryId && $0.categoryId == subject.categoryId
}) ? subject.id : nil
}
if matchedSubjectIDs.isEmpty,
let matchedSubject = matchedExam.subjects.first(where: { String($0.categoryId) == categoryId }) {
selectedSubjectIDs = [matchedSubject.id]
selectedSubjectID = matchedSubject.id
} else {
selectedSubjectIDs = matchedSubjectIDs
selectedSubjectID = selectedSubjectIDs.last
}
} else {
selectedSubjectIDs = []
selectedSubjectID = nil
}
}
private func applySubject(_ subject: ExamSubjectOption) {
selectedSubjectID = subject.id
subtitle = subject.displayName
categoryId = String(subject.categoryId)
boxId = subject.boxId.map(String.init) ?? ""
techId = subject.techId.map(String.init) ?? ""
}
private func clearSubjectSelection() {
selectedSubjectIDs = []
selectedSubjectID = nil
subtitle = ""
categoryId = ""
boxId = ""
techId = ""
}
}
struct SelectExamIntentView: View {
let container: AppContainer
@EnvironmentObject private var examContextStore: ExamContextStore
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel: SelectExamIntentViewModel
@State private var expandedSectionIDs: Set<Int> = []
init(container: AppContainer, store: ExamContextStore) {
self.container = container
_viewModel = StateObject(
wrappedValue: SelectExamIntentViewModel(
store: store,
service: container.examContextService
)
)
}
var body: some View {
NavigationView {
ZStack(alignment: .bottom) {
Color(.systemGroupedBackground)
.ignoresSafeArea()
Group {
if viewModel.showSubjectEditor {
subjectEditorContent
} else {
examSelectionContent
}
}
activeBottomBar
}
.navigationTitle(viewModel.showSubjectEditor ? "编辑科目" : "选择考试")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button {
if viewModel.showSubjectEditor {
withAnimation(.easeInOut(duration: 0.2)) {
viewModel.showSubjectEditor = false
}
} else if examContextStore.hasSelection {
examContextStore.dismissSelection()
dismiss()
}
} label: {
Image(systemName: "chevron.left")
.foregroundColor(.primary)
}
.disabled(!viewModel.showSubjectEditor && !examContextStore.hasSelection)
.opacity((viewModel.showSubjectEditor || examContextStore.hasSelection) ? 1 : 0)
}
ToolbarItem(placement: .topBarTrailing) {
if !viewModel.showSubjectEditor {
Button("手动填写") {
viewModel.showAdvancedEditor = true
}
.font(.subheadline.weight(.medium))
}
}
}
}
.navigationViewStyle(StackNavigationViewStyle())
.interactiveDismissDisabled(!examContextStore.hasSelection)
.task {
await viewModel.loadOptionsIfNeeded()
if expandedSectionIDs.isEmpty {
expandedSectionIDs = Set(viewModel.sections.prefix(2).map(\.id))
}
}
.sheet(isPresented: $viewModel.showAdvancedEditor) {
advancedEditor
}
}
private var examSelectionContent: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 14) {
selectedCard
if viewModel.isLoadingOptions && viewModel.sections.isEmpty {
loadingCard
}
ForEach(viewModel.sections) { section in
groupCard(section)
}
footerMessages
.padding(.bottom, 100)
}
.padding(.horizontal, 14)
.padding(.top, 14)
}
}
private var subjectEditorContent: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 16) {
subjectEditorHeader
subjectManageSection
subjectAllSection
footerMessages
.padding(.bottom, 112)
}
.padding(.horizontal, 16)
.padding(.top, 16)
}
}
private var selectedCard: some View {
VStack(alignment: .leading, spacing: 14) {
Text("当前考试意向")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.secondary)
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 6) {
if let persistedContext = examContextStore.context {
Text(persistedContext.displayName)
.font(.system(size: 22, weight: .bold))
.foregroundColor(.primary)
Text(summaryDescription(for: persistedContext))
.font(.system(size: 13))
.foregroundColor(.secondary)
} else {
Text("请选择你的考试方向")
.font(.system(size: 22, weight: .bold))
.foregroundColor(.primary)
Text("考试意向是题库、课程和报告等页面的统一上下文")
.font(.system(size: 13))
.foregroundColor(.secondary)
}
}
Spacer()
if examContextStore.context != nil {
Text("已生效")
.font(.system(size: 12, weight: .bold))
.foregroundColor(.blue)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.blue.opacity(0.08))
.clipShape(Capsule())
}
}
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
LinearGradient(
colors: [Color.blue.opacity(0.08), Color.white],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.stroke(Color.blue.opacity(0.12), lineWidth: 1)
)
}
private var loadingCard: some View {
HStack(spacing: 12) {
ProgressView()
Text("正在加载考试分类和科目...")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.secondary)
Spacer()
}
.padding(18)
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
}
private func groupCard(_ section: ExamIntentSection) -> some View {
let isExpanded = expandedSectionIDs.contains(section.id)
let items = section.items
return VStack(spacing: 0) {
Button {
toggle(section)
} label: {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 6) {
Text(section.title)
.font(.system(size: 18, weight: .bold))
.foregroundColor(.primary)
Text(section.subtitle.isEmpty ? section.headerMeta : "\(section.subtitle) · \(section.headerMeta)")
.font(.system(size: 12))
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.system(size: 15, weight: .semibold))
.foregroundColor(.secondary)
.padding(.top, 4)
}
.padding(18)
}
.buttonStyle(.plain)
if isExpanded {
VStack(alignment: .leading, spacing: 0) {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 112), spacing: 10)], alignment: .leading, spacing: 12) {
ForEach(items) { option in
examChip(option)
}
}
.padding(.horizontal, 18)
.padding(.bottom, 18)
}
} else {
Text(section.collapsedHint)
.font(.system(size: 13))
.foregroundColor(Color(.tertiaryLabel))
.padding(.horizontal, 18)
.padding(.bottom, 18)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.stroke(Color.black.opacity(0.04), lineWidth: 1)
)
}
private func examChip(_ option: ExamIntentOption) -> some View {
let isSelected = viewModel.selectedExamID == option.id
return Button {
viewModel.chooseExamAndEditSubjects(option)
} label: {
HStack(spacing: 8) {
if option.isHot {
Circle()
.fill(Color.orange)
.frame(width: 6, height: 6)
}
Text(option.title)
.lineLimit(1)
}
.font(.system(size: 14, weight: isSelected ? .semibold : .medium))
.foregroundColor(isSelected ? .blue : Color(.secondaryLabel))
.padding(.horizontal, 16)
.frame(height: 40)
.frame(maxWidth: .infinity)
.background(isSelected ? Color.blue.opacity(0.08) : Color.white)
.overlay(
Capsule()
.stroke(isSelected ? Color.blue.opacity(0.35) : Color(.separator), lineWidth: 1)
)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
private var footerMessages: some View {
VStack(alignment: .leading, spacing: 8) {
if let errorMessage = viewModel.errorMessage {
Text(errorMessage)
.font(.footnote)
.foregroundStyle(.red)
}
if let infoMessage = viewModel.infoMessage {
Text(infoMessage)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var bottomBar: some View {
VStack(spacing: 0) {
Rectangle()
.fill(Color.clear)
.frame(height: 16)
.background(
LinearGradient(
colors: [Color(.systemGroupedBackground).opacity(0), Color(.systemGroupedBackground)],
startPoint: .top,
endPoint: .bottom
)
)
HStack {
Spacer()
HStack {
Image(systemName: "hand.tap.fill")
Text("先选择考试,再进入科目编辑页完成设置")
.font(.system(size: 14, weight: .semibold))
}
.foregroundColor(Color(red: 0.28, green: 0.39, blue: 0.58))
Spacer()
}
.frame(height: 54)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
.padding(.horizontal, 14)
.padding(.top, 6)
.padding(.bottom, 18)
.background(Color(.systemGroupedBackground))
}
}
private var activeBottomBar: some View {
Group {
if viewModel.showSubjectEditor {
subjectEditorBottomBar
} else {
bottomBar
}
}
}
private var subjectEditorHeader: some View {
VStack(alignment: .leading, spacing: 14) {
Text("当前考试")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.secondary)
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 8) {
Text(viewModel.selectedExam?.title ?? "请选择考试")
.font(.system(size: 24, weight: .bold))
.foregroundColor(.primary)
Text("请继续确认你的科目,保存后将联动题库、课程和报告等页面。")
.font(.system(size: 13))
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "square.grid.2x2.fill")
.font(.system(size: 20, weight: .semibold))
.foregroundColor(Color(red: 0.16, green: 0.39, blue: 0.78))
.padding(12)
.background(Color.white.opacity(0.75))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
LinearGradient(
colors: [
Color(red: 0.91, green: 0.96, blue: 1.0),
Color.white
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.stroke(Color.blue.opacity(0.12), lineWidth: 1)
)
}
private var subjectEditorBottomBar: some View {
VStack(spacing: 0) {
Rectangle()
.fill(Color.clear)
.frame(height: 20)
.background(
LinearGradient(
colors: [Color(.systemGroupedBackground).opacity(0), Color(.systemGroupedBackground)],
startPoint: .top,
endPoint: .bottom
)
)
Button {
Task { await viewModel.saveSelection() }
} label: {
HStack(spacing: 8) {
if viewModel.isLoading {
ProgressView()
.tint(.white)
}
Text(viewModel.managedSubjects.isEmpty ? "请选择科目" : "完成")
.font(.system(size: 16, weight: .bold))
}
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.frame(height: 54)
.background(
LinearGradient(
colors: [Color(red: 0.16, green: 0.43, blue: 0.97), Color(red: 0.15, green: 0.33, blue: 0.82)],
startPoint: .leading,
endPoint: .trailing
)
)
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
}
.buttonStyle(.plain)
.disabled(viewModel.isLoading || viewModel.managedSubjects.isEmpty)
.opacity(viewModel.isLoading || viewModel.managedSubjects.isEmpty ? 0.55 : 1)
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 18)
.background(Color(.systemGroupedBackground))
}
}
private var subjectManageSection: some View {
VStack(alignment: .leading, spacing: 18) {
VStack(spacing: 4) {
Text("管理我的科目")
.font(.system(size: 18, weight: .bold))
.frame(maxWidth: .infinity, alignment: .center)
Text("已添加 \(viewModel.managedSubjects.count) 个科目,可继续增减")
.font(.system(size: 12, weight: .medium))
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
}
if viewModel.managedSubjects.isEmpty {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(style: StrokeStyle(lineWidth: 1, dash: [5, 4]))
.foregroundColor(Color(.systemGray4))
.frame(height: 92)
.overlay(
Text("点击添加科目,我们才会更了解你的需求哦~")
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color(.systemGray))
)
} else {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 136), spacing: 12)], spacing: 12) {
ForEach(viewModel.managedSubjects) { subject in
managedSubjectChip(subject)
}
}
}
}
.padding(20)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
}
private var subjectAllSection: some View {
VStack(alignment: .leading, spacing: 18) {
VStack(spacing: 4) {
Text("全部科目")
.font(.system(size: 18, weight: .bold))
.frame(maxWidth: .infinity, alignment: .center)
Text("点击右侧加号添加,当前阶段仅开放注册安全工程师相关方向")
.font(.system(size: 12, weight: .medium))
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
}
if viewModel.allSubjects.isEmpty {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color(.systemGray6))
.frame(height: 88)
.overlay(
Text("当前考试暂未查询到可选科目")
.font(.system(size: 14, weight: .medium))
.foregroundColor(.secondary)
)
} else {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 132), spacing: 12)], spacing: 12) {
ForEach(viewModel.allSubjects) { subject in
allSubjectChip(subject)
}
}
}
}
.padding(20)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
}
private func managedSubjectChip(_ option: ExamSubjectOption) -> some View {
Button {
viewModel.toggleManagedSubject(option)
} label: {
HStack(spacing: 8) {
Text(option.displayName)
.lineLimit(1)
Spacer(minLength: 0)
Image(systemName: "minus")
.font(.system(size: 12, weight: .bold))
}
.padding(.horizontal, 14)
.frame(height: 56)
.frame(maxWidth: .infinity)
.background(Color(red: 0.95, green: 0.98, blue: 1.0))
.foregroundColor(Color(red: 0.14, green: 0.36, blue: 0.74))
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(Color.blue.opacity(0.18), lineWidth: 1)
)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
.buttonStyle(.plain)
}
private func allSubjectChip(_ option: ExamSubjectOption) -> some View {
let isSelected = viewModel.selectedSubjectIDs.contains(option.id)
return Button {
viewModel.toggleManagedSubject(option)
} label: {
HStack(spacing: 8) {
Text(option.displayName)
.lineLimit(1)
Spacer(minLength: 0)
Image(systemName: isSelected ? "checkmark" : "plus")
.font(.system(size: 12, weight: .bold))
}
.padding(.horizontal, 14)
.frame(height: 56)
.frame(maxWidth: .infinity)
.background(isSelected ? Color(red: 0.95, green: 0.98, blue: 1.0) : Color.white)
.foregroundColor(isSelected ? Color(red: 0.14, green: 0.36, blue: 0.74) : Color.primary)
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(isSelected ? Color.blue.opacity(0.22) : Color(.systemGray5), lineWidth: 1)
)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
.buttonStyle(.plain)
}
private var advancedEditor: some View {
NavigationView {
ScrollView {
VStack(spacing: 14) {
examField("考试名称", text: $viewModel.title)
examField("说明", text: $viewModel.subtitle)
examField("categoryId", text: $viewModel.categoryId)
examField("secondCategoryId", text: $viewModel.secondCategoryId)
examField("boxId", text: $viewModel.boxId)
examField("techId / bookId", text: $viewModel.techId)
}
.padding(20)
}
.navigationTitle("手动配置")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("关闭") {
viewModel.showAdvancedEditor = false
}
}
ToolbarItem(placement: .topBarTrailing) {
if #available(iOS 16.0, *) {
Button("完成") {
viewModel.showAdvancedEditor = false
}
.fontWeight(.semibold)
} else {
// Fallback on earlier versions
}
}
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
private func toggle(_ section: ExamIntentSection) {
if expandedSectionIDs.contains(section.id) {
expandedSectionIDs.remove(section.id)
} else {
expandedSectionIDs.insert(section.id)
}
}
private func summaryDescription(for context: ExerciseContext) -> String {
if let matchedExam = viewModel.examOptions.first(where: { $0.secondCategoryId == context.secondCategoryId }),
let matchedSubject = matchedExam.subjects.first(where: { $0.categoryId == context.categoryId }) {
return "\(matchedExam.sectionTitle) · \(matchedSubject.displayName)"
}
return context.detailText
}
}
private func examField(_ title: String, text: Binding<String>) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.footnote)
.foregroundStyle(.secondary)
TextField(title, text: text)
.textFieldStyle(.roundedBorder)
}
}