HAM Operator Quiz

A macOS application written in Swift by Justin Oros.
This commit is contained in:
Justin Oros
2025-07-02 15:42:40 -07:00
committed by GitHub
parent c660d02d49
commit 5fd2eaeba0
12 changed files with 771 additions and 0 deletions
@@ -0,0 +1,326 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXFileReference section */
976BF73F2E15D180008D9B42 /* HAM Operator Quiz.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "HAM Operator Quiz.app"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
976BF7412E15D180008D9B42 /* HAM Operator Quiz */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = "HAM Operator Quiz";
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
976BF73C2E15D180008D9B42 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
976BF7362E15D180008D9B42 = {
isa = PBXGroup;
children = (
976BF7412E15D180008D9B42 /* HAM Operator Quiz */,
976BF7402E15D180008D9B42 /* Products */,
);
sourceTree = "<group>";
};
976BF7402E15D180008D9B42 /* Products */ = {
isa = PBXGroup;
children = (
976BF73F2E15D180008D9B42 /* HAM Operator Quiz.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
976BF73E2E15D180008D9B42 /* HAM Operator Quiz */ = {
isa = PBXNativeTarget;
buildConfigurationList = 976BF74B2E15D182008D9B42 /* Build configuration list for PBXNativeTarget "HAM Operator Quiz" */;
buildPhases = (
976BF73B2E15D180008D9B42 /* Sources */,
976BF73C2E15D180008D9B42 /* Frameworks */,
976BF73D2E15D180008D9B42 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
976BF7412E15D180008D9B42 /* HAM Operator Quiz */,
);
name = "HAM Operator Quiz";
packageProductDependencies = (
);
productName = "HAM Operator Quiz";
productReference = 976BF73F2E15D180008D9B42 /* HAM Operator Quiz.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
976BF7372E15D180008D9B42 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1640;
LastUpgradeCheck = 1640;
TargetAttributes = {
976BF73E2E15D180008D9B42 = {
CreatedOnToolsVersion = 16.4;
};
};
};
buildConfigurationList = 976BF73A2E15D180008D9B42 /* Build configuration list for PBXProject "HAM Operator Quiz" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 976BF7362E15D180008D9B42;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = 976BF7402E15D180008D9B42 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
976BF73E2E15D180008D9B42 /* HAM Operator Quiz */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
976BF73D2E15D180008D9B42 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
976BF73B2E15D180008D9B42 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
976BF7492E15D182008D9B42 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 5RZLK3W7ZH;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 15.5;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
976BF74A2E15D182008D9B42 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 5RZLK3W7ZH;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 15.5;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
};
976BF74C2E15D182008D9B42 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "HAM Operator Quiz/HAM_Operator_Quiz.entitlements";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 5RZLK3W7ZH;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.justinoros.HAM-Operator-Quiz";
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
name = Debug;
};
976BF74D2E15D182008D9B42 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "HAM Operator Quiz/HAM_Operator_Quiz.entitlements";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 5RZLK3W7ZH;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "com.justinoros.HAM-Operator-Quiz";
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
976BF73A2E15D180008D9B42 /* Build configuration list for PBXProject "HAM Operator Quiz" */ = {
isa = XCConfigurationList;
buildConfigurations = (
976BF7492E15D182008D9B42 /* Debug */,
976BF74A2E15D182008D9B42 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
976BF74B2E15D182008D9B42 /* Build configuration list for PBXNativeTarget "HAM Operator Quiz" */ = {
isa = XCConfigurationList;
buildConfigurations = (
976BF74C2E15D182008D9B42 /* Debug */,
976BF74D2E15D182008D9B42 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 976BF7372E15D180008D9B42 /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>HAM Operator Quiz.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,58 @@
{
"images" : [
{
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,47 @@
import Foundation
struct FileSync {
static let baseURL = "https://raw.githubusercontent.com/russolsen/ham_radio_question_pool/master"
static let testPaths = [
"technician": "technician-2022-2026/technician.json",
"general": "general-2023-2027/general.json",
"extra": "extra-2024-2028/extra.json"
]
static func urlForTest(_ test: String) -> URL? {
guard let path = testPaths[test.lowercased()] else { return nil }
return URL(string: "\(baseURL)/\(path)")
}
static func localFileURL(test: String) -> URL? {
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.appendingPathComponent("\(test).json")
}
static func downloadAndSave(test: String, completion: @escaping (Result<Void, Error>) -> Void) {
guard
let url = urlForTest(test),
let localURL = localFileURL(test: test)
else {
completion(.failure(NSError(domain: "Invalid URL or file path", code: 0)))
return
}
let task = URLSession.shared.dataTask(with: url) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let data = data else {
completion(.failure(NSError(domain: "No data", code: 0)))
return
}
do {
try data.write(to: localURL)
completion(.success(()))
} catch {
completion(.failure(error))
}
}
task.resume()
}
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,13 @@
import SwiftUI
@main
struct HamOperatorQuizApp: App {
var body: some Scene {
WindowGroup {
NavigationStack {
TestSelectionView()
}
.frame(minWidth: 300, minHeight: 200)
}
}
}
@@ -0,0 +1,252 @@
import SwiftUI
struct QuizQuestion: Codable, Identifiable {
let id: String
let question: String
let correct: Int
let answers: [String]
}
struct QuizView: View {
let testName: String
@State private var questions: [QuizQuestion] = []
@State private var currentIndex = 0
@State private var selectedAnswer: String? = nil
@State private var score = 0
@State private var feedbackText = ""
@State private var feedbackColor = Color.primary
@State private var loadError = false
@State private var showResults = false
@State private var autoAdvanceWorkItem: DispatchWorkItem? = nil
var body: some View {
VStack(alignment: .leading, spacing: 20) {
if loadError {
Text("Could not load questions for \(testName).")
.foregroundColor(.red)
.padding()
} else if questions.isEmpty {
Text("Loading questions...")
.padding()
} else if currentIndex < questions.count {
Text(questions[currentIndex].question)
.font(.headline)
.fixedSize(horizontal: false, vertical: true)
ForEach(questions[currentIndex].answers, id: \.self) { answer in
Button(action: {
selectedAnswer = answer
checkAnswer()
scheduleAutoNext()
}) {
Text(answer)
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
Capsule()
.fill(bubbleColor(for: answer))
)
.foregroundColor(.white)
}
.buttonStyle(PlainButtonStyle())
.disabled(selectedAnswer != nil) // Disable after selection
}
Text(feedbackText)
.foregroundColor(feedbackColor)
HStack {
Button("Next") {
cancelAutoNext()
nextQuestion()
}
.disabled(selectedAnswer == nil)
Button("Quit") {
cancelAutoNext()
showFinalResultsAndExit()
}
}
} else {
VStack(spacing: 10) {
Text("Quiz Complete!")
.font(.title)
let percentage = questions.count > 0 ? Int((Double(score) / Double(questions.count)) * 100) : 0
Text("You got \(score) out of \(questions.count) correct. (\(percentage)%)")
Button("Quit") {
NSApp.terminate(nil)
}
}
}
}
.padding()
.frame(minWidth: 600, minHeight: 400)
.onAppear(perform: syncAndLoadQuestions)
.navigationTitle(testName + " Exam")
}
func bubbleColor(for answer: String) -> Color {
let imessageBlue = Color(red: 0.0, green: 122/255, blue: 1.0)
let correctAnswer = questions[currentIndex].answers[questions[currentIndex].correct]
if let selected = selectedAnswer {
if selected == answer && selected == correctAnswer {
return .green
} else if selected == answer && selected != correctAnswer {
// Selected wrong answer -> red bubble
return .red
} else if answer == correctAnswer && selected != correctAnswer {
// Highlight correct answer bubble green if selected answer is wrong
return .green
}
}
return imessageBlue
}
func showFinalResultsAndExit() {
let percentage = currentIndex > 0 ? Int((Double(score) / Double(currentIndex + 1)) * 100) : 0
feedbackText = "You answered \(score) out of \(currentIndex + 1) correctly. (\(percentage)% correct)"
feedbackColor = .primary
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
NSApp.terminate(nil)
}
}
func scheduleAutoNext() {
cancelAutoNext() // Cancel any existing auto advance
autoAdvanceWorkItem = DispatchWorkItem {
if selectedAnswer != nil && currentIndex < questions.count - 1 {
nextQuestion()
}
}
if let workItem = autoAdvanceWorkItem {
DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: workItem)
}
}
func cancelAutoNext() {
autoAdvanceWorkItem?.cancel()
autoAdvanceWorkItem = nil
}
func syncAndLoadQuestions() {
guard let documentsDir = getDocumentsDirectory() else {
loadError = true
return
}
let lowerName = testName.lowercased()
let filename = "\(lowerName).json"
let urlMap = [
"technician": "https://raw.githubusercontent.com/russolsen/ham_radio_question_pool/master/technician-2022-2026/technician.json",
"general": "https://raw.githubusercontent.com/russolsen/ham_radio_question_pool/master/general-2023-2027/general.json",
"extra": "https://raw.githubusercontent.com/russolsen/ham_radio_question_pool/master/extra-2024-2028/extra.json"
]
guard let remoteUrlString = urlMap[lowerName],
let remoteUrl = URL(string: remoteUrlString) else {
loadError = true
return
}
let localFileURL = documentsDir.appendingPathComponent(filename)
let fileExists = FileManager.default.fileExists(atPath: localFileURL.path)
if !fileExists {
downloadAndSave(remoteUrl: remoteUrl, to: localFileURL)
} else {
URLSession.shared.dataTask(with: remoteUrl) { data, response, error in
if let data = data,
let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 {
do {
let remoteQuestions = try JSONDecoder().decode([QuizQuestion].self, from: data)
if let localData = try? Data(contentsOf: localFileURL),
let localQuestions = try? JSONDecoder().decode([QuizQuestion].self, from: localData),
localQuestions.count == remoteQuestions.count {
loadQuestions(from: localFileURL)
} else {
try data.write(to: localFileURL)
loadQuestions(from: localFileURL)
}
} catch {
print("Failed sync check: \(error)")
loadQuestions(from: localFileURL)
}
} else {
print("Remote check failed: \(error?.localizedDescription ?? "Unknown")")
loadQuestions(from: localFileURL)
}
}.resume()
}
}
func downloadAndSave(remoteUrl: URL, to localUrl: URL) {
URLSession.shared.dataTask(with: remoteUrl) { data, response, error in
if let data = data,
let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 {
do {
try data.write(to: localUrl)
loadQuestions(from: localUrl)
} catch {
print("Failed to save downloaded JSON: \(error)")
loadError = true
}
} else {
print("Download failed: \(error?.localizedDescription ?? "Unknown")")
loadError = true
}
}.resume()
}
func loadQuestions(from fileURL: URL) {
DispatchQueue.global(qos: .background).async {
do {
let data = try Data(contentsOf: fileURL)
let loadedQuestions = try JSONDecoder().decode([QuizQuestion].self, from: data)
DispatchQueue.main.async {
if loadedQuestions.isEmpty {
loadError = true
} else {
questions = loadedQuestions.shuffled()
loadError = false
}
}
} catch {
print("Failed to parse questions: \(error)")
DispatchQueue.main.async {
loadError = true
}
}
}
}
func checkAnswer() {
let correctAnswer = questions[currentIndex].answers[questions[currentIndex].correct]
if selectedAnswer == correctAnswer {
score += 1
feedbackText = "Correct!"
feedbackColor = .green
} else {
feedbackText = "Incorrect! Correct answer: \(correctAnswer)"
feedbackColor = .red
}
}
func nextQuestion() {
cancelAutoNext()
currentIndex += 1
selectedAnswer = nil
feedbackText = ""
}
func getDocumentsDirectory() -> URL? {
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
}
}
@@ -0,0 +1,25 @@
import SwiftUI
struct TestSelectionView: View {
let testOptions = ["Technician Class", "General Class", "Extra Class"]
var body: some View {
VStack {
List(testOptions, id: \.self) { test in
NavigationLink(value: test) {
Text(test)
}
}
.navigationTitle("Select Test")
.navigationDestination(for: String.self) { selectedTest in
QuizView(testName: selectedTest.replacingOccurrences(of: " Class", with: ""))
}
.frame(minWidth: 400, minHeight: 300)
Button("Quit") {
NSApp.terminate(nil)
}
.padding(.bottom, 10)
}
}
}