├── .gitignore ├── README.md ├── Sudoku.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ ├── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ │ └── Package.resolved │ └── xcuserdata │ │ └── raykim.xcuserdatad │ │ └── UserInterfaceState.xcuserstate ├── xcshareddata │ └── xcschemes │ │ ├── Sudoku.xcscheme │ │ └── SudokuTests.xcscheme └── xcuserdata │ └── RaymondKim.xcuserdatad │ ├── xcdebugger │ └── Breakpoints_v2.xcbkptlist │ └── xcschemes │ └── xcschememanagement.plist ├── Sudoku ├── API.swift ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ └── background (6).png │ ├── AppIcon.solidimagestack │ │ ├── Back.solidimagestacklayer │ │ │ ├── Content.imageset │ │ │ │ ├── Contents.json │ │ │ │ └── background (7).png │ │ │ └── Contents.json │ │ ├── Contents.json │ │ └── Middle.solidimagestacklayer │ │ │ ├── Content.imageset │ │ │ ├── Contents.json │ │ │ └── grid (2).png │ │ │ └── Contents.json │ ├── Contents.json │ ├── dynamicBackground.colorset │ │ └── Contents.json │ ├── dynamicBlack.colorset │ │ └── Contents.json │ ├── dynamicBlue.colorset │ │ └── Contents.json │ ├── dynamicBlueSelection.colorset │ │ └── Contents.json │ ├── dynamicDarkGray.colorset │ │ └── Contents.json │ ├── dynamicGray.colorset │ │ └── Contents.json │ ├── dynamicGridSelection.colorset │ │ └── Contents.json │ └── dynamicGridWhite.colorset │ │ └── Contents.json ├── Base.lproj │ └── LaunchScreen.storyboard ├── Game │ ├── ClearButton.swift │ ├── EditButton.swift │ ├── GameView.swift │ ├── GameViewModel.swift │ ├── Grid │ │ ├── EditCellGrid.swift │ │ ├── EditCellGridText.swift │ │ ├── GridContainerView.swift │ │ ├── Row.swift │ │ ├── RowButtonText.swift │ │ ├── RowViewModel.swift │ │ ├── Square.swift │ │ ├── SquareViewModel.swift │ │ └── SudokuGrid.swift │ ├── HintButton.swift │ ├── KeysRow.swift │ ├── KeysRowButtonText.swift │ └── NewGameButton.swift ├── How to Play │ ├── HowToPlayView.swift │ ├── StaticGridView.swift │ ├── StaticRowView.swift │ └── StaticSquareView.swift ├── Info.plist ├── Menu │ ├── GameLevelButton.swift │ ├── MenuNavigationLinks.swift │ ├── MenuView.swift │ └── MenuViewModel.swift ├── Models │ ├── AlertItem.swift │ ├── ChatResponse.swift │ ├── CoordinateColor.swift │ ├── CoordinateEditValues.swift │ ├── CoordinateValue.swift │ ├── Difficulty.swift │ ├── EditGridValues.swift │ ├── EditState.swift │ ├── GameConfig.swift │ ├── GridFactory.swift │ ├── GridValues.swift │ ├── Hint.swift │ ├── SelectedCell.swift │ ├── UndoManager.swift │ ├── UserAction.swift │ └── WindowSize.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── SettingsView.swift ├── Shared │ ├── AnyShape.swift │ ├── Extensions.swift │ ├── View Modifiers │ │ ├── CornerRadiusStyle.swift │ │ ├── DynamicButtonStyle.swift │ │ ├── FullBackgroundStyle.swift │ │ └── ShimmerEffect.swift │ └── ViewModel.swift ├── Stats │ ├── StatsRow.swift │ └── StatsView.swift ├── SudokuApp.swift └── SudokuSolver.swift └── SudokuTests └── RowViewModelTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Sensitive Files 6 | 7 | ## Build generated 8 | build/ 9 | DerivedData/ 10 | Pods/ 11 | 12 | ## Various settings 13 | *.DS_Store 14 | *.pbxuser 15 | !default.pbxuser 16 | *.mode1v3 17 | !default.mode1v3 18 | *.mode2v3 19 | !default.mode2v3 20 | *.perspectivev3 21 | !default.perspectivev3 22 | xcuserdata/ 23 | xcdebugger/ 24 | 25 | ## Other 26 | *.moved-aside 27 | *.xccheckout 28 | *.xcscmblueprint 29 | *.orig 30 | 31 | ## Obj-C/Swift specific 32 | *.hmap 33 | *.ipa 34 | *.dSYM.zip 35 | *.dSYM 36 | 37 | ## Playgrounds 38 | timeline.xctimeline 39 | playground.xcworkspace 40 | 41 | 42 | # fastlane 43 | # 44 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 45 | # screenshots whenever they are needed. 46 | # For more information about the recommended setup visit: 47 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 48 | 49 | fastlane/report.xml 50 | fastlane/Preview.html 51 | fastlane/screenshots 52 | fastlane/test_output 53 | Sudoku.xcodeproj/project.xcworkspace/xcuserdata/raykim.xcuserdatad/UserInterfaceState.xcuserstate 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sudoku 2 | Sudoku iOS app written using SwiftUI 3 | 4 | [Privacy Policy](https://rckim77.github.io/Sudoku-Site/) 5 | -------------------------------------------------------------------------------- /Sudoku.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 70; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | EC3E0F942CE1330E0026AA8C /* AIProxy in Frameworks */ = {isa = PBXBuildFile; productRef = EC3E0F932CE1330E0026AA8C /* AIProxy */; }; 11 | /* End PBXBuildFile section */ 12 | 13 | /* Begin PBXContainerItemProxy section */ 14 | EC7D0FAF2BBB98BF0035FB6D /* PBXContainerItemProxy */ = { 15 | isa = PBXContainerItemProxy; 16 | containerPortal = 94E108772487333A0091D043 /* Project object */; 17 | proxyType = 1; 18 | remoteGlobalIDString = 94E1087E2487333A0091D043; 19 | remoteInfo = Sudoku; 20 | }; 21 | /* End PBXContainerItemProxy section */ 22 | 23 | /* Begin PBXFileReference section */ 24 | 94E1087F2487333A0091D043 /* Sudoku.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sudoku.app; sourceTree = BUILT_PRODUCTS_DIR; }; 25 | EC7D0FAB2BBB98BE0035FB6D /* SudokuTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SudokuTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 26 | /* End PBXFileReference section */ 27 | 28 | /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ 29 | EC90CD2D2CCC302A005F4BE3 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { 30 | isa = PBXFileSystemSynchronizedBuildFileExceptionSet; 31 | membershipExceptions = ( 32 | Info.plist, 33 | ); 34 | target = 94E1087E2487333A0091D043 /* Sudoku */; 35 | }; 36 | /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ 37 | 38 | /* Begin PBXFileSystemSynchronizedRootGroup section */ 39 | EC90CCB62CCC3027005F4BE3 /* SudokuTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = SudokuTests; sourceTree = ""; }; 40 | EC90CCF62CCC302A005F4BE3 /* Sudoku */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (EC90CD2D2CCC302A005F4BE3 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Sudoku; sourceTree = ""; }; 41 | /* End PBXFileSystemSynchronizedRootGroup section */ 42 | 43 | /* Begin PBXFrameworksBuildPhase section */ 44 | 94E1087C2487333A0091D043 /* Frameworks */ = { 45 | isa = PBXFrameworksBuildPhase; 46 | buildActionMask = 2147483647; 47 | files = ( 48 | EC3E0F942CE1330E0026AA8C /* AIProxy in Frameworks */, 49 | ); 50 | runOnlyForDeploymentPostprocessing = 0; 51 | }; 52 | EC7D0FA82BBB98BE0035FB6D /* Frameworks */ = { 53 | isa = PBXFrameworksBuildPhase; 54 | buildActionMask = 2147483647; 55 | files = ( 56 | ); 57 | runOnlyForDeploymentPostprocessing = 0; 58 | }; 59 | /* End PBXFrameworksBuildPhase section */ 60 | 61 | /* Begin PBXGroup section */ 62 | 94E108762487333A0091D043 = { 63 | isa = PBXGroup; 64 | children = ( 65 | EC90CCF62CCC302A005F4BE3 /* Sudoku */, 66 | EC90CCB62CCC3027005F4BE3 /* SudokuTests */, 67 | 94E108802487333A0091D043 /* Products */, 68 | ); 69 | sourceTree = ""; 70 | }; 71 | 94E108802487333A0091D043 /* Products */ = { 72 | isa = PBXGroup; 73 | children = ( 74 | 94E1087F2487333A0091D043 /* Sudoku.app */, 75 | EC7D0FAB2BBB98BE0035FB6D /* SudokuTests.xctest */, 76 | ); 77 | name = Products; 78 | sourceTree = ""; 79 | }; 80 | /* End PBXGroup section */ 81 | 82 | /* Begin PBXNativeTarget section */ 83 | 94E1087E2487333A0091D043 /* Sudoku */ = { 84 | isa = PBXNativeTarget; 85 | buildConfigurationList = 94E108932487333F0091D043 /* Build configuration list for PBXNativeTarget "Sudoku" */; 86 | buildPhases = ( 87 | 94E1087B2487333A0091D043 /* Sources */, 88 | 94E1087C2487333A0091D043 /* Frameworks */, 89 | 94E1087D2487333A0091D043 /* Resources */, 90 | ); 91 | buildRules = ( 92 | ); 93 | dependencies = ( 94 | ); 95 | fileSystemSynchronizedGroups = ( 96 | EC90CCF62CCC302A005F4BE3 /* Sudoku */, 97 | ); 98 | name = Sudoku; 99 | productName = Sudoku; 100 | productReference = 94E1087F2487333A0091D043 /* Sudoku.app */; 101 | productType = "com.apple.product-type.application"; 102 | }; 103 | EC7D0FAA2BBB98BE0035FB6D /* SudokuTests */ = { 104 | isa = PBXNativeTarget; 105 | buildConfigurationList = EC7D0FB32BBB98BF0035FB6D /* Build configuration list for PBXNativeTarget "SudokuTests" */; 106 | buildPhases = ( 107 | EC7D0FA72BBB98BE0035FB6D /* Sources */, 108 | EC7D0FA82BBB98BE0035FB6D /* Frameworks */, 109 | EC7D0FA92BBB98BE0035FB6D /* Resources */, 110 | ); 111 | buildRules = ( 112 | ); 113 | dependencies = ( 114 | EC7D0FB02BBB98BF0035FB6D /* PBXTargetDependency */, 115 | ); 116 | fileSystemSynchronizedGroups = ( 117 | EC90CCB62CCC3027005F4BE3 /* SudokuTests */, 118 | ); 119 | name = SudokuTests; 120 | productName = SudokuTests; 121 | productReference = EC7D0FAB2BBB98BE0035FB6D /* SudokuTests.xctest */; 122 | productType = "com.apple.product-type.bundle.unit-test"; 123 | }; 124 | /* End PBXNativeTarget section */ 125 | 126 | /* Begin PBXProject section */ 127 | 94E108772487333A0091D043 /* Project object */ = { 128 | isa = PBXProject; 129 | attributes = { 130 | BuildIndependentTargetsInParallel = YES; 131 | LastSwiftUpdateCheck = 1520; 132 | LastUpgradeCheck = 1520; 133 | ORGANIZATIONNAME = Self; 134 | TargetAttributes = { 135 | 94E1087E2487333A0091D043 = { 136 | CreatedOnToolsVersion = 11.4.1; 137 | }; 138 | EC7D0FAA2BBB98BE0035FB6D = { 139 | CreatedOnToolsVersion = 15.2; 140 | TestTargetID = 94E1087E2487333A0091D043; 141 | }; 142 | }; 143 | }; 144 | buildConfigurationList = 94E1087A2487333A0091D043 /* Build configuration list for PBXProject "Sudoku" */; 145 | compatibilityVersion = "Xcode 9.3"; 146 | developmentRegion = en; 147 | hasScannedForEncodings = 0; 148 | knownRegions = ( 149 | en, 150 | Base, 151 | ); 152 | mainGroup = 94E108762487333A0091D043; 153 | packageReferences = ( 154 | EC3E0F922CE1330E0026AA8C /* XCRemoteSwiftPackageReference "aiproxyswift" */, 155 | ); 156 | productRefGroup = 94E108802487333A0091D043 /* Products */; 157 | projectDirPath = ""; 158 | projectRoot = ""; 159 | targets = ( 160 | 94E1087E2487333A0091D043 /* Sudoku */, 161 | EC7D0FAA2BBB98BE0035FB6D /* SudokuTests */, 162 | ); 163 | }; 164 | /* End PBXProject section */ 165 | 166 | /* Begin PBXResourcesBuildPhase section */ 167 | 94E1087D2487333A0091D043 /* Resources */ = { 168 | isa = PBXResourcesBuildPhase; 169 | buildActionMask = 2147483647; 170 | files = ( 171 | ); 172 | runOnlyForDeploymentPostprocessing = 0; 173 | }; 174 | EC7D0FA92BBB98BE0035FB6D /* Resources */ = { 175 | isa = PBXResourcesBuildPhase; 176 | buildActionMask = 2147483647; 177 | files = ( 178 | ); 179 | runOnlyForDeploymentPostprocessing = 0; 180 | }; 181 | /* End PBXResourcesBuildPhase section */ 182 | 183 | /* Begin PBXSourcesBuildPhase section */ 184 | 94E1087B2487333A0091D043 /* Sources */ = { 185 | isa = PBXSourcesBuildPhase; 186 | buildActionMask = 2147483647; 187 | files = ( 188 | ); 189 | runOnlyForDeploymentPostprocessing = 0; 190 | }; 191 | EC7D0FA72BBB98BE0035FB6D /* Sources */ = { 192 | isa = PBXSourcesBuildPhase; 193 | buildActionMask = 2147483647; 194 | files = ( 195 | ); 196 | runOnlyForDeploymentPostprocessing = 0; 197 | }; 198 | /* End PBXSourcesBuildPhase section */ 199 | 200 | /* Begin PBXTargetDependency section */ 201 | EC7D0FB02BBB98BF0035FB6D /* PBXTargetDependency */ = { 202 | isa = PBXTargetDependency; 203 | target = 94E1087E2487333A0091D043 /* Sudoku */; 204 | targetProxy = EC7D0FAF2BBB98BF0035FB6D /* PBXContainerItemProxy */; 205 | }; 206 | /* End PBXTargetDependency section */ 207 | 208 | /* Begin XCBuildConfiguration section */ 209 | 94E108912487333F0091D043 /* Debug */ = { 210 | isa = XCBuildConfiguration; 211 | buildSettings = { 212 | ALWAYS_SEARCH_USER_PATHS = NO; 213 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 214 | CLANG_ANALYZER_NONNULL = YES; 215 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 216 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 217 | CLANG_CXX_LIBRARY = "libc++"; 218 | CLANG_ENABLE_MODULES = YES; 219 | CLANG_ENABLE_OBJC_ARC = YES; 220 | CLANG_ENABLE_OBJC_WEAK = YES; 221 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 222 | CLANG_WARN_BOOL_CONVERSION = YES; 223 | CLANG_WARN_COMMA = YES; 224 | CLANG_WARN_CONSTANT_CONVERSION = YES; 225 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 226 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 227 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 228 | CLANG_WARN_EMPTY_BODY = YES; 229 | CLANG_WARN_ENUM_CONVERSION = YES; 230 | CLANG_WARN_INFINITE_RECURSION = YES; 231 | CLANG_WARN_INT_CONVERSION = YES; 232 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 233 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 234 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 235 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 236 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 237 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 238 | CLANG_WARN_STRICT_PROTOTYPES = YES; 239 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 240 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 241 | CLANG_WARN_UNREACHABLE_CODE = YES; 242 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 243 | COPY_PHASE_STRIP = NO; 244 | DEBUG_INFORMATION_FORMAT = dwarf; 245 | ENABLE_STRICT_OBJC_MSGSEND = YES; 246 | ENABLE_TESTABILITY = YES; 247 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 248 | GCC_C_LANGUAGE_STANDARD = gnu11; 249 | GCC_DYNAMIC_NO_PIC = NO; 250 | GCC_NO_COMMON_BLOCKS = YES; 251 | GCC_OPTIMIZATION_LEVEL = 0; 252 | GCC_PREPROCESSOR_DEFINITIONS = ( 253 | "DEBUG=1", 254 | "$(inherited)", 255 | ); 256 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 257 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 258 | GCC_WARN_UNDECLARED_SELECTOR = YES; 259 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 260 | GCC_WARN_UNUSED_FUNCTION = YES; 261 | GCC_WARN_UNUSED_VARIABLE = YES; 262 | IPHONEOS_DEPLOYMENT_TARGET = 17.0; 263 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 264 | MTL_FAST_MATH = YES; 265 | ONLY_ACTIVE_ARCH = YES; 266 | SDKROOT = iphoneos; 267 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 268 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 269 | XROS_DEPLOYMENT_TARGET = 1.0; 270 | }; 271 | name = Debug; 272 | }; 273 | 94E108922487333F0091D043 /* Release */ = { 274 | isa = XCBuildConfiguration; 275 | buildSettings = { 276 | ALWAYS_SEARCH_USER_PATHS = NO; 277 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 278 | CLANG_ANALYZER_NONNULL = YES; 279 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 280 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 281 | CLANG_CXX_LIBRARY = "libc++"; 282 | CLANG_ENABLE_MODULES = YES; 283 | CLANG_ENABLE_OBJC_ARC = YES; 284 | CLANG_ENABLE_OBJC_WEAK = YES; 285 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 286 | CLANG_WARN_BOOL_CONVERSION = YES; 287 | CLANG_WARN_COMMA = YES; 288 | CLANG_WARN_CONSTANT_CONVERSION = YES; 289 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 290 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 291 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 292 | CLANG_WARN_EMPTY_BODY = YES; 293 | CLANG_WARN_ENUM_CONVERSION = YES; 294 | CLANG_WARN_INFINITE_RECURSION = YES; 295 | CLANG_WARN_INT_CONVERSION = YES; 296 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 297 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 298 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 299 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 300 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 301 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 302 | CLANG_WARN_STRICT_PROTOTYPES = YES; 303 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 304 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 305 | CLANG_WARN_UNREACHABLE_CODE = YES; 306 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 307 | COPY_PHASE_STRIP = NO; 308 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 309 | ENABLE_NS_ASSERTIONS = NO; 310 | ENABLE_STRICT_OBJC_MSGSEND = YES; 311 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 312 | GCC_C_LANGUAGE_STANDARD = gnu11; 313 | GCC_NO_COMMON_BLOCKS = YES; 314 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 315 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 316 | GCC_WARN_UNDECLARED_SELECTOR = YES; 317 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 318 | GCC_WARN_UNUSED_FUNCTION = YES; 319 | GCC_WARN_UNUSED_VARIABLE = YES; 320 | IPHONEOS_DEPLOYMENT_TARGET = 17.0; 321 | MTL_ENABLE_DEBUG_INFO = NO; 322 | MTL_FAST_MATH = YES; 323 | SDKROOT = iphoneos; 324 | SWIFT_COMPILATION_MODE = wholemodule; 325 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 326 | VALIDATE_PRODUCT = YES; 327 | XROS_DEPLOYMENT_TARGET = 1.0; 328 | }; 329 | name = Release; 330 | }; 331 | 94E108942487333F0091D043 /* Debug */ = { 332 | isa = XCBuildConfiguration; 333 | buildSettings = { 334 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 335 | CODE_SIGN_STYLE = Automatic; 336 | CURRENT_PROJECT_VERSION = 30; 337 | DEVELOPMENT_ASSET_PATHS = "\"Sudoku/Preview Content\""; 338 | DEVELOPMENT_TEAM = 49BZEB79BF; 339 | ENABLE_PREVIEWS = YES; 340 | INFOPLIST_FILE = Sudoku/Info.plist; 341 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.puzzle-games"; 342 | IPHONEOS_DEPLOYMENT_TARGET = 17.0; 343 | LD_RUNPATH_SEARCH_PATHS = ( 344 | "$(inherited)", 345 | "@executable_path/Frameworks", 346 | ); 347 | MARKETING_VERSION = 4.12.0; 348 | PRODUCT_BUNDLE_IDENTIFIER = com.rckim.Sudoku; 349 | PRODUCT_NAME = "$(TARGET_NAME)"; 350 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; 351 | SUPPORTS_MACCATALYST = NO; 352 | SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; 353 | SWIFT_STRICT_CONCURRENCY = complete; 354 | SWIFT_UPCOMING_FEATURE_CONCISE_MAGIC_FILE = YES; 355 | SWIFT_UPCOMING_FEATURE_DEPRECATE_APPLICATION_MAIN = YES; 356 | SWIFT_UPCOMING_FEATURE_DISABLE_OUTWARD_ACTOR_ISOLATION = YES; 357 | SWIFT_UPCOMING_FEATURE_FORWARD_TRAILING_CLOSURES = YES; 358 | SWIFT_UPCOMING_FEATURE_GLOBAL_CONCURRENCY = YES; 359 | SWIFT_UPCOMING_FEATURE_IMPLICIT_OPEN_EXISTENTIALS = YES; 360 | SWIFT_UPCOMING_FEATURE_IMPORT_OBJC_FORWARD_DECLS = YES; 361 | SWIFT_UPCOMING_FEATURE_INFER_SENDABLE_FROM_CAPTURES = YES; 362 | SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = YES; 363 | SWIFT_UPCOMING_FEATURE_REGION_BASED_ISOLATION = YES; 364 | SWIFT_VERSION = 5.0; 365 | TARGETED_DEVICE_FAMILY = "1,2,7"; 366 | XROS_DEPLOYMENT_TARGET = 1.0; 367 | }; 368 | name = Debug; 369 | }; 370 | 94E108952487333F0091D043 /* Release */ = { 371 | isa = XCBuildConfiguration; 372 | buildSettings = { 373 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 374 | CODE_SIGN_STYLE = Automatic; 375 | CURRENT_PROJECT_VERSION = 30; 376 | DEVELOPMENT_ASSET_PATHS = "\"Sudoku/Preview Content\""; 377 | DEVELOPMENT_TEAM = 49BZEB79BF; 378 | ENABLE_PREVIEWS = YES; 379 | INFOPLIST_FILE = Sudoku/Info.plist; 380 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.puzzle-games"; 381 | IPHONEOS_DEPLOYMENT_TARGET = 17.0; 382 | LD_RUNPATH_SEARCH_PATHS = ( 383 | "$(inherited)", 384 | "@executable_path/Frameworks", 385 | ); 386 | MARKETING_VERSION = 4.12.0; 387 | PRODUCT_BUNDLE_IDENTIFIER = com.rckim.Sudoku; 388 | PRODUCT_NAME = "$(TARGET_NAME)"; 389 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; 390 | SUPPORTS_MACCATALYST = NO; 391 | SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; 392 | SWIFT_STRICT_CONCURRENCY = complete; 393 | SWIFT_UPCOMING_FEATURE_CONCISE_MAGIC_FILE = YES; 394 | SWIFT_UPCOMING_FEATURE_DEPRECATE_APPLICATION_MAIN = YES; 395 | SWIFT_UPCOMING_FEATURE_DISABLE_OUTWARD_ACTOR_ISOLATION = YES; 396 | SWIFT_UPCOMING_FEATURE_FORWARD_TRAILING_CLOSURES = YES; 397 | SWIFT_UPCOMING_FEATURE_GLOBAL_CONCURRENCY = YES; 398 | SWIFT_UPCOMING_FEATURE_IMPLICIT_OPEN_EXISTENTIALS = YES; 399 | SWIFT_UPCOMING_FEATURE_IMPORT_OBJC_FORWARD_DECLS = YES; 400 | SWIFT_UPCOMING_FEATURE_INFER_SENDABLE_FROM_CAPTURES = YES; 401 | SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = YES; 402 | SWIFT_UPCOMING_FEATURE_REGION_BASED_ISOLATION = YES; 403 | SWIFT_VERSION = 5.0; 404 | TARGETED_DEVICE_FAMILY = "1,2,7"; 405 | XROS_DEPLOYMENT_TARGET = 1.0; 406 | }; 407 | name = Release; 408 | }; 409 | EC7D0FB12BBB98BF0035FB6D /* Debug */ = { 410 | isa = XCBuildConfiguration; 411 | buildSettings = { 412 | BUNDLE_LOADER = "$(TEST_HOST)"; 413 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 414 | CODE_SIGN_STYLE = Automatic; 415 | CURRENT_PROJECT_VERSION = 1; 416 | DEVELOPMENT_TEAM = 49BZEB79BF; 417 | GCC_C_LANGUAGE_STANDARD = gnu17; 418 | GENERATE_INFOPLIST_FILE = YES; 419 | IPHONEOS_DEPLOYMENT_TARGET = 17.2; 420 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 421 | MARKETING_VERSION = 1.0; 422 | PRODUCT_BUNDLE_IDENTIFIER = rckim.SudokuTests; 423 | PRODUCT_NAME = "$(TARGET_NAME)"; 424 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; 425 | SUPPORTS_MACCATALYST = NO; 426 | SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; 427 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 428 | SWIFT_EMIT_LOC_STRINGS = NO; 429 | SWIFT_VERSION = 5.0; 430 | TARGETED_DEVICE_FAMILY = "1,2,7"; 431 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Sudoku.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Sudoku"; 432 | }; 433 | name = Debug; 434 | }; 435 | EC7D0FB22BBB98BF0035FB6D /* Release */ = { 436 | isa = XCBuildConfiguration; 437 | buildSettings = { 438 | BUNDLE_LOADER = "$(TEST_HOST)"; 439 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 440 | CODE_SIGN_STYLE = Automatic; 441 | CURRENT_PROJECT_VERSION = 1; 442 | DEVELOPMENT_TEAM = 49BZEB79BF; 443 | GCC_C_LANGUAGE_STANDARD = gnu17; 444 | GENERATE_INFOPLIST_FILE = YES; 445 | IPHONEOS_DEPLOYMENT_TARGET = 17.2; 446 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 447 | MARKETING_VERSION = 1.0; 448 | PRODUCT_BUNDLE_IDENTIFIER = rckim.SudokuTests; 449 | PRODUCT_NAME = "$(TARGET_NAME)"; 450 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; 451 | SUPPORTS_MACCATALYST = NO; 452 | SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; 453 | SWIFT_EMIT_LOC_STRINGS = NO; 454 | SWIFT_VERSION = 5.0; 455 | TARGETED_DEVICE_FAMILY = "1,2,7"; 456 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Sudoku.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Sudoku"; 457 | }; 458 | name = Release; 459 | }; 460 | /* End XCBuildConfiguration section */ 461 | 462 | /* Begin XCConfigurationList section */ 463 | 94E1087A2487333A0091D043 /* Build configuration list for PBXProject "Sudoku" */ = { 464 | isa = XCConfigurationList; 465 | buildConfigurations = ( 466 | 94E108912487333F0091D043 /* Debug */, 467 | 94E108922487333F0091D043 /* Release */, 468 | ); 469 | defaultConfigurationIsVisible = 0; 470 | defaultConfigurationName = Release; 471 | }; 472 | 94E108932487333F0091D043 /* Build configuration list for PBXNativeTarget "Sudoku" */ = { 473 | isa = XCConfigurationList; 474 | buildConfigurations = ( 475 | 94E108942487333F0091D043 /* Debug */, 476 | 94E108952487333F0091D043 /* Release */, 477 | ); 478 | defaultConfigurationIsVisible = 0; 479 | defaultConfigurationName = Release; 480 | }; 481 | EC7D0FB32BBB98BF0035FB6D /* Build configuration list for PBXNativeTarget "SudokuTests" */ = { 482 | isa = XCConfigurationList; 483 | buildConfigurations = ( 484 | EC7D0FB12BBB98BF0035FB6D /* Debug */, 485 | EC7D0FB22BBB98BF0035FB6D /* Release */, 486 | ); 487 | defaultConfigurationIsVisible = 0; 488 | defaultConfigurationName = Release; 489 | }; 490 | /* End XCConfigurationList section */ 491 | 492 | /* Begin XCRemoteSwiftPackageReference section */ 493 | EC3E0F922CE1330E0026AA8C /* XCRemoteSwiftPackageReference "aiproxyswift" */ = { 494 | isa = XCRemoteSwiftPackageReference; 495 | repositoryURL = "https://github.com/lzell/aiproxyswift"; 496 | requirement = { 497 | branch = main; 498 | kind = branch; 499 | }; 500 | }; 501 | /* End XCRemoteSwiftPackageReference section */ 502 | 503 | /* Begin XCSwiftPackageProductDependency section */ 504 | EC3E0F932CE1330E0026AA8C /* AIProxy */ = { 505 | isa = XCSwiftPackageProductDependency; 506 | package = EC3E0F922CE1330E0026AA8C /* XCRemoteSwiftPackageReference "aiproxyswift" */; 507 | productName = AIProxy; 508 | }; 509 | /* End XCSwiftPackageProductDependency section */ 510 | }; 511 | rootObject = 94E108772487333A0091D043 /* Project object */; 512 | } 513 | -------------------------------------------------------------------------------- /Sudoku.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Sudoku.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Sudoku.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "4222888b151bbfeb3af2cc2401ef09a1c4a9d2995c29bb137828fe949aaea9d0", 3 | "pins" : [ 4 | { 5 | "identity" : "aiproxyswift", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/lzell/aiproxyswift", 8 | "state" : { 9 | "branch" : "main", 10 | "revision" : "2dac6b8d720d10b0cfa9c97d95f896f82f80f938" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | -------------------------------------------------------------------------------- /Sudoku.xcodeproj/project.xcworkspace/xcuserdata/raykim.xcuserdatad/UserInterfaceState.xcuserstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rckim77/Sudoku/3497ddac284653968d5e1cd3ae2076f0c1b0296c/Sudoku.xcodeproj/project.xcworkspace/xcuserdata/raykim.xcuserdatad/UserInterfaceState.xcuserstate -------------------------------------------------------------------------------- /Sudoku.xcodeproj/xcshareddata/xcschemes/Sudoku.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 34 | 40 | 41 | 42 | 43 | 44 | 54 | 56 | 62 | 63 | 64 | 65 | 69 | 70 | 71 | 72 | 78 | 80 | 86 | 87 | 88 | 89 | 91 | 92 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /Sudoku.xcodeproj/xcshareddata/xcschemes/SudokuTests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 16 | 19 | 25 | 26 | 27 | 28 | 29 | 39 | 40 | 46 | 47 | 49 | 50 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /Sudoku.xcodeproj/xcuserdata/RaymondKim.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /Sudoku.xcodeproj/xcuserdata/RaymondKim.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | Sudoku.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | SuppressBuildableAutocreation 14 | 15 | 94E1087E2487333A0091D043 16 | 17 | primary 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /Sudoku/API.swift: -------------------------------------------------------------------------------- 1 | // 2 | // API.swift 3 | // Sudoku 4 | // 5 | // Created by Ray Kim on 12/22/23. 6 | // Copyright © 2023 Self. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AIProxy 11 | 12 | struct API { 13 | 14 | enum APIError: Error { 15 | case invalidURL, quotaExceeded 16 | } 17 | 18 | /// Helper method for POST requests sending JSON with basic error handling 19 | static func postURLRequest(url: String, requestBody: [String: Any]) throws -> URLRequest { 20 | guard let url = URL(string: url) else { 21 | throw APIError.invalidURL 22 | } 23 | 24 | let jsonData = try JSONSerialization.data(withJSONObject: requestBody) 25 | 26 | var urlRequest = URLRequest(url: url) 27 | urlRequest.httpMethod = "POST" 28 | urlRequest.httpBody = jsonData 29 | urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type") 30 | 31 | return urlRequest 32 | } 33 | 34 | static func getHint(grid: [CoordinateValue], difficulty: Difficulty.Level) async throws -> Hint? { 35 | if let singleHint = SudokuSolver.findSingles(grid).first { 36 | return singleHint 37 | } 38 | 39 | let openAIService = AIProxy.openAIService( 40 | partialKey: "v2|c7d0ff39|qrIzn_OLLetLdcWN", 41 | serviceURL: "https://api.aiproxy.pro/c160196f/657d65d2" 42 | ) 43 | 44 | let stringGrid = GridFactory.stringGridFor(grid: grid) 45 | let difficultyString = difficulty.rawValue.lowercased() 46 | 47 | let content = """ 48 | You are a Sudoku assistant. The following is a Sudoku puzzle of \(difficultyString) difficulty. 49 | Empty cells are marked as 0. The grid is represented as 9 arrays, each representing a 3x3 square 50 | from left to right, top to bottom: 51 | 52 | \(stringGrid) 53 | 54 | Provide one specific, accurate hint following the following rules: 55 | 1. For easy difficulty: Look for obvious patterns or scanning techniques 56 | 2. For medium difficulty: Suggest looking for hidden pairs or pointing pairs 57 | 3. For hard difficulty: Guide towards advanced techniques like X-Wings or XY-Wings 58 | 4. Always verify your hint is valid and doesn't violate Sudoku rules 59 | 5. Use at most 2 sentences and never give direct solutions 60 | 6. Use natural language when referring to positions (e.g., 'third row from top') 61 | """ 62 | 63 | do { 64 | let requestBody = OpenAIChatCompletionRequestBody( 65 | model: "gpt-4o-mini", 66 | messages: [.system(content: .text(content))] 67 | ) 68 | let response = try await openAIService.chatCompletionRequest(body: requestBody) 69 | let hint = Hint(coordinate: nil, hintType: .open, overrideDescription: response.choices.first?.message.content) 70 | return hint 71 | } catch AIProxyError.unsuccessfulRequest(statusCode: let statusCode, responseBody: let responseBody) { 72 | print("Received \(statusCode) status code with response body: \(responseBody)") 73 | if statusCode == 429 { 74 | throw APIError.quotaExceeded 75 | } 76 | return nil 77 | } catch { 78 | print("Could not create OpenAI chat completion: \(error.localizedDescription)") 79 | return nil 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Sudoku/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "background (6).png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sudoku/Assets.xcassets/AppIcon.appiconset/background (6).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rckim77/Sudoku/3497ddac284653968d5e1cd3ae2076f0c1b0296c/Sudoku/Assets.xcassets/AppIcon.appiconset/background (6).png -------------------------------------------------------------------------------- /Sudoku/Assets.xcassets/AppIcon.solidimagestack/Back.solidimagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "background (7).png", 5 | "idiom" : "vision", 6 | "scale" : "2x" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sudoku/Assets.xcassets/AppIcon.solidimagestack/Back.solidimagestacklayer/Content.imageset/background (7).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rckim77/Sudoku/3497ddac284653968d5e1cd3ae2076f0c1b0296c/Sudoku/Assets.xcassets/AppIcon.solidimagestack/Back.solidimagestacklayer/Content.imageset/background (7).png -------------------------------------------------------------------------------- /Sudoku/Assets.xcassets/AppIcon.solidimagestack/Back.solidimagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sudoku/Assets.xcassets/AppIcon.solidimagestack/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | }, 6 | "layers" : [ 7 | { 8 | "filename" : "Middle.solidimagestacklayer" 9 | }, 10 | { 11 | "filename" : "Back.solidimagestacklayer" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /Sudoku/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "grid (2).png", 5 | "idiom" : "vision", 6 | "scale" : "2x" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sudoku/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Content.imageset/grid (2).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rckim77/Sudoku/3497ddac284653968d5e1cd3ae2076f0c1b0296c/Sudoku/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Content.imageset/grid (2).png -------------------------------------------------------------------------------- /Sudoku/Assets.xcassets/AppIcon.solidimagestack/Middle.solidimagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sudoku/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sudoku/Assets.xcassets/dynamicBackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "1.000", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.152", 27 | "green" : "0.152", 28 | "red" : "0.152" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sudoku/Assets.xcassets/dynamicBlack.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.000", 9 | "green" : "0.000", 10 | "red" : "0.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.577", 27 | "green" : "0.577", 28 | "red" : "0.577" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sudoku/Assets.xcassets/dynamicBlue.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "0.285", 10 | "red" : "0.053" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.859", 27 | "green" : "0.246", 28 | "red" : "0.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sudoku/Assets.xcassets/dynamicBlueSelection.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "0.652", 10 | "red" : "0.487" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sudoku/Assets.xcassets/dynamicDarkGray.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.455", 9 | "green" : "0.455", 10 | "red" : "0.455" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.455", 27 | "green" : "0.455", 28 | "red" : "0.455" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sudoku/Assets.xcassets/dynamicGray.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.729", 9 | "green" : "0.729", 10 | "red" : "0.729" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.587", 27 | "green" : "0.587", 28 | "red" : "0.587" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sudoku/Assets.xcassets/dynamicGridSelection.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.854", 9 | "green" : "0.854", 10 | "red" : "0.854" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.598", 27 | "green" : "0.598", 28 | "red" : "0.598" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sudoku/Assets.xcassets/dynamicGridWhite.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "1.000", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.746", 27 | "green" : "0.746", 28 | "red" : "0.746" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sudoku/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /Sudoku/Game/ClearButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClearButton.swift 3 | // Sudoku 4 | // 5 | // Created by Raymond Kim on 6/16/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ClearButton: View { 12 | 13 | let selectedCoordinate: Coordinate? 14 | var editGrid: EditGridValues 15 | var editState: EditState 16 | var userAction: UserAction 17 | var workingGrid: GridValues 18 | @Binding var savedState: SavedState 19 | var undoManager: UndoManager 20 | 21 | var body: some View { 22 | Button("Clear", systemImage: "eraser") { 23 | guard let selectedCoordinate = self.selectedCoordinate, 24 | !self.workingGrid.containsAValue(at: selectedCoordinate, grid: workingGrid.startingGrid) else { 25 | return 26 | } 27 | 28 | // Only track the action if there's something to clear 29 | let hasValue = workingGrid.getValue(at: selectedCoordinate, grid: workingGrid.grid) != nil 30 | let hasGuesses = !(editGrid.guesses(for: selectedCoordinate)?.values.isEmpty ?? true) 31 | 32 | if hasValue || hasGuesses { 33 | let previousValue = workingGrid.getValue(at: selectedCoordinate, grid: workingGrid.grid) 34 | let previousEditValues = editGrid.guesses(for: selectedCoordinate)?.values ?? Set() 35 | 36 | undoManager.addAction(UndoAction( 37 | coordinate: selectedCoordinate, 38 | previousValue: previousValue, 39 | previousEditValues: previousEditValues, 40 | wasEditing: editState.isEditing, 41 | editActionType: .none 42 | )) 43 | 44 | self.savedState = .unsaved 45 | self.userAction.action = .clear 46 | 47 | // clear guesses regardless of editing mode 48 | self.editGrid.removeValues(at: selectedCoordinate) 49 | 50 | if !self.editState.isEditing { 51 | self.workingGrid.removeValue(at: selectedCoordinate) 52 | } 53 | } 54 | } 55 | .tint(.primary) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sudoku/Game/EditButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EditButton.swift 3 | // Sudoku 4 | // 5 | // Created by Raymond Kim on 6/16/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct EditButton: View { 12 | 13 | var editState: EditState 14 | 15 | private var imageSize: CGFloat { 16 | isIpad ? 24 : 18 17 | } 18 | private var backgroundColor: Color { 19 | editState.isEditing ? Color("dynamicDarkGray") : Color("dynamicGray") 20 | } 21 | private var imageName: String { 22 | editState.isEditing ? "pencil.circle.fill" : "pencil.circle" 23 | } 24 | 25 | var body: some View { 26 | Button("Edit", systemImage: editState.isEditing ? "square.and.pencil.circle.fill" : "square.and.pencil.circle") { 27 | self.editState.isEditing.toggle() 28 | } 29 | .contentTransition(.symbolEffect(.replace)) 30 | .tint(.primary) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sudoku/Game/GameView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GameView.swift 3 | // Sudoku 4 | // 5 | // Created by Ray Kim on 6/30/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import SwiftData 11 | 12 | enum SavedState: Codable { 13 | case startedUnsaved 14 | case startedSaved 15 | case saved 16 | case unsaved 17 | } 18 | 19 | struct GameView: View { 20 | 21 | @Environment(\.dismiss) private var dismiss 22 | @Environment(\.modelContext) private var modelContext 23 | 24 | @Query private var savedGameState: [GameConfig] 25 | 26 | @State var savedState: SavedState 27 | @State private(set) var selectedCell = SelectedCell() 28 | @State private(set) var userAction = UserAction() 29 | @State private(set) var editState = EditState(isEditing: false) 30 | @State private(set) var workingGrid: GridValues 31 | @State private(set) var editGrid = EditGridValues(grid: []) 32 | @State private var alertItem: AlertItem? 33 | @State private var alertIsPresented: Bool = false 34 | @State private var hintButtonIsLoading: Bool = false 35 | @State private var saveButtonAnimate: Bool = false 36 | @State private(set) var undoManager = UndoManager() 37 | let viewModel: GameViewModel 38 | 39 | // MARK: - Timer 40 | 41 | @State private var elapsedTime: TimeInterval 42 | private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() 43 | 44 | // MARK: - Statistics 45 | 46 | @AppStorage("totalGamesCompleted") var totalGamesCompleted = 0 47 | @AppStorage("totalEasyGamesCompleted") var totalEasyGamesCompleted = 0 48 | @AppStorage("totalMediumGamesCompleted") var totalMediumGamesCompleted = 0 49 | @AppStorage("totalHardGamesCompleted") var totalHardGamesCompleted = 0 50 | @AppStorage("fastestTimeCompleted") var fastestTimeCompleted: TimeInterval? 51 | 52 | // MARK: - Computed properties 53 | 54 | private var hasUpdatedGrid: Bool { 55 | return workingGrid.grid.count > workingGrid.startingGrid.count || !editGrid.isEmpty 56 | } 57 | 58 | private var formattedElapsedTime: String { 59 | let minutes = Int(elapsedTime) / 60 % 60 60 | let seconds = Int(elapsedTime) % 60 61 | return String(format: "%d:%02d", minutes, seconds) 62 | } 63 | 64 | // MARK: - Initialization 65 | 66 | init(_ gameConfig: GameConfig) { 67 | self.savedState = gameConfig.savedState 68 | self.selectedCell = SelectedCell(coordinate: gameConfig.selectedCell) 69 | self.userAction = UserAction(action: gameConfig.userAction) 70 | self.editState = EditState(isEditing: gameConfig.isEditing) 71 | self.workingGrid = GridValues(grid: gameConfig.workingGrid, startingGrid: gameConfig.startingGrid, colorGrid: gameConfig.colorGrid) 72 | self.editGrid = EditGridValues(grid: gameConfig.editValues) 73 | self.viewModel = .init(difficulty: gameConfig.difficulty) 74 | self.elapsedTime = gameConfig.elapsedTime ?? 0 75 | } 76 | 77 | var body: some View { 78 | ZStack { 79 | Color("dynamicBackground") 80 | .edgesIgnoringSafeArea(.all) 81 | GeometryReader { geometry in 82 | VStack(spacing: viewModel.verticalSpacing) { 83 | Spacer() 84 | SudokuGrid(selectedCell: selectedCell, 85 | userAction: userAction, 86 | editGrid: editGrid.grid, 87 | workingGrid: workingGrid) 88 | Spacer() 89 | .frame(maxWidth: .infinity, 90 | maxHeight: viewModel.getSpacerMaxHeight(geometry.size.height)) 91 | KeysRow(editGrid: editGrid, 92 | userAction: userAction, 93 | workingGrid: workingGrid, 94 | alert: $alertItem, 95 | alertIsPresented: $alertIsPresented, 96 | selectedCoordinate: selectedCell.coordinate, 97 | isEditing: editState.isEditing, 98 | savedState: $savedState, 99 | undoManager: undoManager) 100 | Spacer() 101 | .frame(maxHeight: viewModel.verticalSpacing) 102 | ZStack(alignment: .center) { 103 | NewGameButton(alert: $alertItem, 104 | alertIsPresented: $alertIsPresented, 105 | hasUpdatedGrid: hasUpdatedGrid, 106 | savedState: savedState) 107 | Text(formattedElapsedTime) 108 | .font(.system(.headline, design: .rounded)) 109 | .offset(x: viewModel.timerHorizontalOffset) 110 | } 111 | Spacer() 112 | .frame(maxHeight: viewModel.bottomVerticalSpacing) 113 | } 114 | .toolbar { 115 | ToolbarItemGroup(placement: viewModel.toolbarItemPlacement) { 116 | if !isVision { 117 | Spacer() 118 | } 119 | HStack(spacing: viewModel.toolbarItemHorizontalSpacing) { 120 | Button("Undo", systemImage: "arrow.uturn.backward") { 121 | handleUndo() 122 | } 123 | .disabled(!undoManager.canUndo) 124 | .tint(.primary) 125 | 126 | ClearButton(selectedCoordinate: selectedCell.coordinate, 127 | editGrid: editGrid, 128 | editState: editState, 129 | userAction: userAction, 130 | workingGrid: workingGrid, 131 | savedState: $savedState, 132 | undoManager: undoManager) 133 | EditButton(editState: editState) 134 | HintButton(alertItem: $alertItem, 135 | alertIsPresented: $alertIsPresented, 136 | grid: workingGrid.grid, 137 | difficulty: viewModel.difficulty) 138 | Button("Save", systemImage: "square.and.arrow.down") { 139 | checkSaveIfNeeded() 140 | saveButtonAnimate.toggle() 141 | } 142 | .symbolEffect(.bounce.down.byLayer, value: saveButtonAnimate) 143 | .tint(.primary) 144 | } 145 | .padding(.bottom, viewModel.toolbarBottomPadding) 146 | if !isVision { 147 | Spacer() 148 | } 149 | } 150 | } 151 | .alert(alertItem?.title ?? "Alert", 152 | isPresented: $alertIsPresented, 153 | presenting: alertItem 154 | ) { item in 155 | switch item { 156 | case .newGame: 157 | Button(role: .destructive) { 158 | dismiss() 159 | } label: { 160 | Text("Confirm") 161 | } 162 | Button(role: .cancel) {} label: { 163 | Text("Cancel") 164 | } 165 | 166 | case .completedCorrectly: 167 | Button("Go back") { 168 | saveUserStats() 169 | dismiss() 170 | } 171 | case .hintSuccess(_): 172 | Button("Thanks") {} 173 | case .completedIncorrectly, .hintError, .hintErrorQuota: 174 | Button("Dismiss") {} 175 | case .overwriteWarning: 176 | Button(role: .destructive) { 177 | save() 178 | } label: { 179 | Text("Confirm") 180 | } 181 | } 182 | } message: { item in 183 | Text(item.message) 184 | } 185 | } 186 | } 187 | .navigationBarBackButtonHidden(true) 188 | .onReceive(timer) { _ in 189 | elapsedTime += 1 190 | } 191 | .onChange(of: alertIsPresented, { oldValue, newValue in 192 | if newValue, alertItem == .completedCorrectly { 193 | stopTimer() 194 | } 195 | }) 196 | .onDisappear() { 197 | stopTimer() 198 | switch savedState { 199 | case .startedUnsaved: 200 | resetGrids(for: viewModel.difficulty) 201 | case .startedSaved, .saved: 202 | break 203 | case .unsaved: 204 | resetToPreviouslySavedGrid() 205 | } 206 | } 207 | } 208 | 209 | private func stopTimer() { 210 | timer.upstream.connect().cancel() 211 | } 212 | 213 | private func resetGrids(for level: Difficulty.Level) { 214 | workingGrid.resetTo(newGrid: GridFactory.randomGridForDifficulty(level: level)) 215 | selectedCell.coordinate = nil 216 | userAction.action = .none 217 | editState.isEditing = false 218 | editGrid.grid = [] 219 | } 220 | 221 | private func resetToPreviouslySavedGrid() { 222 | guard let savedGame = savedGameState.first else { 223 | return 224 | } 225 | workingGrid.resetFrom(savedGame: savedGame) 226 | selectedCell.coordinate = savedGame.selectedCell 227 | 228 | userAction.action = savedGame.userAction 229 | editState.isEditing = savedGame.isEditing 230 | editGrid.grid = savedGame.editValues 231 | } 232 | 233 | private func checkSaveIfNeeded() { 234 | if !savedGameState.isEmpty { 235 | alertItem = .overwriteWarning 236 | alertIsPresented = true 237 | } else { 238 | save() 239 | } 240 | } 241 | 242 | /// Currently we only save one game. To fetch, always get the first SavedGameState object in the model container. 243 | private func save() { 244 | let gameState = GameConfig(savedState: savedState, workingGrid: workingGrid.grid, startingGrid: workingGrid.startingGrid, colorGrid: workingGrid.colorGrid, userAction: userAction.action, selectedCell: selectedCell.coordinate, isEditing: editState.isEditing, editValues: editGrid.grid, difficulty: viewModel.difficulty, elapsedTime: elapsedTime) 245 | 246 | try? modelContext.delete(model: GameConfig.self) 247 | modelContext.insert(gameState) 248 | try? modelContext.save() 249 | savedState = .saved 250 | } 251 | 252 | private func saveUserStats() { 253 | totalGamesCompleted += 1 254 | 255 | switch viewModel.difficulty { 256 | case .easy: 257 | totalEasyGamesCompleted += 1 258 | case .medium: 259 | totalMediumGamesCompleted += 1 260 | case .hard: 261 | totalHardGamesCompleted += 1 262 | } 263 | 264 | if let currentFastestTime = fastestTimeCompleted { 265 | fastestTimeCompleted = min(elapsedTime, currentFastestTime) 266 | } else { 267 | fastestTimeCompleted = elapsedTime 268 | } 269 | } 270 | 271 | private func handleUndo() { 272 | guard let lastAction = undoManager.undo() else { return } 273 | 274 | editState.isEditing = lastAction.wasEditing 275 | 276 | if lastAction.wasEditing { 277 | switch lastAction.editActionType { 278 | case .add(let digit): 279 | // If we were adding a digit, remove it 280 | var newValues = lastAction.previousEditValues 281 | newValues.remove(digit) 282 | updateEditValues(at: lastAction.coordinate, with: newValues) 283 | case .remove(let digit): 284 | // If we were removing a digit, add it back 285 | var newValues = lastAction.previousEditValues 286 | newValues.insert(digit) 287 | updateEditValues(at: lastAction.coordinate, with: newValues) 288 | case .none: 289 | // Handle regular value changes and clearAll 290 | updateEditValues(at: lastAction.coordinate, with: lastAction.previousEditValues) 291 | } 292 | } else { 293 | if let previousValue = lastAction.previousValue { 294 | workingGrid.add(CoordinateValue(r: lastAction.coordinate.r, 295 | c: lastAction.coordinate.c, 296 | s: lastAction.coordinate.s, 297 | v: previousValue)) 298 | } else { 299 | workingGrid.removeValue(at: lastAction.coordinate) 300 | } 301 | 302 | updateEditValues(at: lastAction.coordinate, with: lastAction.previousEditValues) 303 | } 304 | 305 | savedState = .unsaved 306 | } 307 | 308 | private func updateEditValues(at coordinate: Coordinate, with values: Set) { 309 | editGrid.removeValues(at: coordinate) 310 | if !values.isEmpty { 311 | let editValues = CoordinateEditValues(r: coordinate.r, 312 | c: coordinate.c, 313 | s: coordinate.s, 314 | values: values) 315 | editGrid.grid.append(editValues) 316 | } 317 | } 318 | } 319 | 320 | #Preview { 321 | GeometryReader { geometry in 322 | let startingGrid = GridFactory.randomGridForDifficulty(level: .easy) 323 | let gameConfig = GameConfig(savedState: .startedUnsaved, 324 | workingGrid: startingGrid, 325 | startingGrid: startingGrid, 326 | difficulty: .easy, 327 | elapsedTime: 0) 328 | GameView(gameConfig) 329 | .environment(WindowSize(size: geometry.size)) 330 | } 331 | } 332 | -------------------------------------------------------------------------------- /Sudoku/Game/GameViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GameViewModel.swift 3 | // Sudoku 4 | // 5 | // Created by Ray Kim on 6/25/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct GameViewModel: ViewModel { 12 | 13 | let difficulty: Difficulty.Level 14 | 15 | let bottomVerticalSpacing: CGFloat = 32 16 | 17 | var verticalSpacing: CGFloat { 18 | isVision ? 8 : 16 19 | } 20 | 21 | var timerHorizontalOffset: CGFloat { 22 | isIpad ? 140 : 100 23 | } 24 | 25 | var toolbarItemPlacement: ToolbarItemPlacement { 26 | #if os(visionOS) 27 | return .bottomOrnament 28 | #else 29 | return .bottomBar 30 | #endif 31 | } 32 | 33 | let toolbarItemHorizontalSpacing: CGFloat = 24 34 | 35 | var toolbarBottomPadding: CGFloat { 36 | if isIpad { 37 | return 32 38 | } else if isVision { 39 | return 0 40 | } else { 41 | return 16 42 | } 43 | } 44 | 45 | func getSpacerMaxHeight(_ geometryHeight: CGFloat) -> CGFloat { 46 | let verticalThreshold: CGFloat = 900 47 | 48 | if isVision { 49 | return 8 50 | } else if geometryHeight > verticalThreshold { 51 | return 60 52 | } else { 53 | return 16 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sudoku/Game/Grid/EditCellGrid.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EditCellGrid.swift 3 | // Sudoku 4 | // 5 | // Created by Raymond Kim on 6/15/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct EditCellGrid: View { 12 | 13 | @Environment(WindowSize.self) private var windowSize 14 | 15 | let values: Set 16 | 17 | private var minHeight: CGFloat { 18 | (windowSize.size.width - (2 * SudokuGrid.spacerWidth)) / 9 19 | } 20 | 21 | private var verticalSpacing: CGFloat { 22 | if isIphone { 23 | return -4 24 | } else if isIpad { 25 | return 2 26 | } else { 27 | return 0 28 | } 29 | } 30 | 31 | var body: some View { 32 | Grid(horizontalSpacing: 0, verticalSpacing: verticalSpacing) { 33 | GridRow { 34 | EditCellGridText(digitText: text(for: 0)) 35 | EditCellGridText(digitText: text(for: 1)) 36 | EditCellGridText(digitText: text(for: 2)) 37 | } 38 | GridRow { 39 | EditCellGridText(digitText: text(for: 3)) 40 | EditCellGridText(digitText: text(for: 4)) 41 | EditCellGridText(digitText: text(for: 5)) 42 | } 43 | GridRow { 44 | EditCellGridText(digitText: text(for: 6)) 45 | EditCellGridText(digitText: text(for: 7)) 46 | EditCellGridText(digitText: text(for: 8)) 47 | } 48 | } 49 | .frame(maxWidth: .infinity) 50 | .frame(height: getHeight(size: windowSize.size)) 51 | } 52 | 53 | private func getHeight(size: CGSize) -> CGFloat { 54 | if isVision { 55 | let horizontalPadding = abs(size.width - size.height) 56 | // other UI elements below the grid 57 | let extraVerticalOffset: CGFloat = 24 58 | return ((size.width - horizontalPadding) / 9) - extraVerticalOffset 59 | } else { 60 | return minHeight 61 | } 62 | } 63 | 64 | private func text(for editIndex: Int) -> String { 65 | if values.contains(where: { $0 == editIndex + 1 }) { 66 | return "\(editIndex + 1)" 67 | } else { 68 | return "" 69 | } 70 | } 71 | } 72 | 73 | #Preview { 74 | GeometryReader { geometry in 75 | EditCellGrid(values: Set(arrayLiteral: 1, 2, 3, 4, 7, 8, 9)) 76 | .environment(WindowSize(size: geometry.size)) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Sudoku/Game/Grid/EditCellGridText.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EditCellGridText.swift 3 | // Sudoku 4 | // 5 | // Created by Ray Kim on 6/29/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct EditCellGridText: View { 12 | 13 | let digitText: String 14 | 15 | private var font: Font.TextStyle { 16 | isIphone ? .footnote : .headline 17 | } 18 | 19 | var body: some View { 20 | Text(digitText) 21 | .foregroundColor(Color("dynamicBlue")) 22 | .font(.system(font, design: .rounded)) 23 | .minimumScaleFactor(0.6) 24 | .frame(maxWidth: .infinity) 25 | } 26 | } 27 | 28 | struct EditCellGridText_Previews: PreviewProvider { 29 | static var previews: some View { 30 | EditCellGridText(digitText: "0") 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sudoku/Game/Grid/GridContainerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GridContainerView.swift 3 | // Sudoku 4 | // 5 | // Created by Ray Kim on 7/20/24. 6 | // Copyright © 2024 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct GridContainerView: View { 12 | 13 | let content: (Int) -> Content 14 | 15 | private let borderWidth: CGFloat = 2 16 | private let squareRowRanges = [(0...2), (3...5), (6...8)] 17 | 18 | init(@ViewBuilder content: @escaping (Int) -> Content) { 19 | self.content = content 20 | } 21 | 22 | var body: some View { 23 | Grid(horizontalSpacing: 0, verticalSpacing: 0) { 24 | ForEach(squareRowRanges, id: \.self) { squareRowRange in 25 | GridRow { 26 | ForEach(squareRowRange, id: \.self) { squareIndex in 27 | content(squareIndex) 28 | } 29 | } 30 | } 31 | } 32 | .padding(borderWidth) 33 | .border(.black, width: borderWidth) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sudoku/Game/Grid/Row.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Row.swift 3 | // Sudoku 4 | // 5 | // Created by Raymond Kim on 6/2/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct Row: View { 12 | 13 | var selectedCell: SelectedCell 14 | var userAction: UserAction 15 | let workingGrid: GridValues 16 | 17 | let viewModel: RowViewModel 18 | 19 | var body: some View { 20 | GridRow { 21 | ForEach(viewModel.columns, id: \.self) { columnIndex in 22 | Button(action: { 23 | self.updateSelectedButton(columnIndex: columnIndex) 24 | }) { 25 | self.renderCellText(columnIndex: columnIndex) 26 | } 27 | #if os(visionOS) 28 | .buttonStyle(PlainButtonStyle()) // this improves edit grid layout 29 | #else 30 | .buttonStyle(DefaultButtonStyle()) 31 | #endif 32 | .buttonBorderShape(.roundedRectangle(radius: 0)) // keeps UI consistent on visionOS 33 | .border(Color.black, width: 1) 34 | .background(self.viewModel.backgroundColorFor(columnIndex, selectedCell: self.selectedCell.coordinate)) 35 | } 36 | } 37 | } 38 | 39 | private func isSelected(columnIndex: Int) -> Bool { 40 | return selectedCell.coordinate?.r == viewModel.index && 41 | selectedCell.coordinate?.c == columnIndex && 42 | selectedCell.coordinate?.s == viewModel.squareIndex 43 | } 44 | 45 | private func setRowButtonText(columnIndex: Int) -> String { 46 | let coordinate = Coordinate(r: viewModel.index, c: columnIndex, s: viewModel.squareIndex) 47 | if let value = workingGrid.getValue(at: coordinate, grid: workingGrid.grid) { 48 | return "\(value)" 49 | } else { 50 | return "" 51 | } 52 | } 53 | 54 | private func updateSelectedButton(columnIndex: Int) { 55 | if !isSelected(columnIndex: columnIndex) { 56 | selectedCell.coordinate = Coordinate(r: viewModel.index, c: viewModel.columns[columnIndex], s: viewModel.squareIndex) 57 | } else { 58 | selectedCell.coordinate = nil 59 | } 60 | userAction.action = .none 61 | } 62 | 63 | // Note: Use AnyView to type erase View. 64 | private func renderCellText(columnIndex: Int) -> AnyView { 65 | let coordinate = Coordinate(r: viewModel.index, c: columnIndex, s: viewModel.squareIndex) 66 | let guesses = viewModel.guessesFor(columnIndex) 67 | 68 | if !workingGrid.containsAValue(at: coordinate, grid: workingGrid.grid) && !guesses.isEmpty { 69 | return AnyView(EditCellGrid(values: guesses)) 70 | } else { 71 | let text = setRowButtonText(columnIndex: columnIndex) 72 | if let coordinateValue = workingGrid.getCoordinateValue(at: coordinate, grid: workingGrid.grid) { 73 | let foregroundColor = workingGrid.foregroundColorFor(coordinateValue) ?? .black 74 | return AnyView(RowButtonText(text: text, foregroundColor: foregroundColor, isStatic: false)) 75 | } else { 76 | return AnyView(RowButtonText(text: text, foregroundColor: .black, isStatic: false)) 77 | } 78 | } 79 | } 80 | } 81 | 82 | #Preview { 83 | GeometryReader { geometry in 84 | Row(selectedCell: SelectedCell(), 85 | userAction: UserAction(), 86 | workingGrid: GridValues(startingGrid: GridFactory.easyGrid), 87 | viewModel: RowViewModel(index: 2, 88 | squareIndex: 0, 89 | guesses: [])) 90 | .environment(WindowSize(size: geometry.size)) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Sudoku/Game/Grid/RowButtonText.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RowButtonText.swift 3 | // Sudoku 4 | // 5 | // Created by Raymond Kim on 6/3/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct RowButtonText: View { 12 | 13 | @Environment(WindowSize.self) private var windowSize 14 | 15 | let text: String 16 | let foregroundColor: Color 17 | let isStatic: Bool 18 | 19 | private var buttonTextFont: Font { 20 | let textStyle: Font.TextStyle = isIpad ? .largeTitle : .title 21 | return Font.system(textStyle, design: .rounded).bold() 22 | } 23 | 24 | private var buttonMinHeight: CGFloat { 25 | let spacerWidth = isStatic ? StaticGridView.spacerWidth : SudokuGrid.spacerWidth 26 | return (windowSize.size.width - (2 * spacerWidth)) / 9 27 | } 28 | 29 | var body: some View { 30 | Text(text) 31 | .foregroundColor(foregroundColor) 32 | .font(buttonTextFont) 33 | .frame(maxWidth: .infinity) 34 | .frame(height: getHeight(size: windowSize.size)) 35 | } 36 | 37 | private func getHeight(size: CGSize) -> CGFloat { 38 | if isVision && !isStatic { 39 | let horizontalPadding = abs(size.width - size.height) 40 | let extraVerticalOffset: CGFloat = 24 // other UI elements below the grid 41 | return ((size.width - horizontalPadding) / 9) - extraVerticalOffset 42 | } else { 43 | return buttonMinHeight 44 | } 45 | } 46 | } 47 | 48 | #Preview { 49 | GeometryReader { geometry in 50 | VStack { 51 | RowButtonText(text: "1", foregroundColor: .black, isStatic: true) 52 | .environment(WindowSize(size: geometry.size)) 53 | RowButtonText(text: "", foregroundColor: .black, isStatic: true) 54 | .environment(WindowSize(size: geometry.size)) 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sudoku/Game/Grid/RowViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RowViewModel.swift 3 | // Sudoku 4 | // 5 | // Created by Ray Kim on 6/24/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct RowViewModel { 12 | 13 | let index: Int 14 | let columns = [0, 1, 2] 15 | let squareIndex: Int 16 | let guesses: [CoordinateEditValues] 17 | 18 | private let backgroundColor = Color("dynamicGridWhite") 19 | private let selectedBackgroundColor = Color("dynamicGridSelection") 20 | 21 | func backgroundColorFor(_ columnIndex: Int, selectedCell: Coordinate?) -> Color { 22 | guard let selectedCell = selectedCell else { 23 | return backgroundColor 24 | } 25 | 26 | let isinSameSquare = squareIndex == selectedCell.s 27 | let isInSameColumn = coordinateAt(columnIndex, isInSameColumnAs: selectedCell) 28 | let isInSameRow = coordinateAt(columnIndex, isInSameRowAs: selectedCell) 29 | 30 | // exact same coordinate 31 | if selectedCell.r == index && selectedCell.c == columnIndex && selectedCell.s == squareIndex { 32 | return Color("dynamicBlueSelection") 33 | } else if isinSameSquare || isInSameColumn || isInSameRow { 34 | return selectedBackgroundColor 35 | } else { 36 | return backgroundColor 37 | } 38 | } 39 | 40 | func guessesFor(_ columnIndex: Int) -> Set { 41 | let coordinate = (r: index, c: columnIndex, s: squareIndex) 42 | return guesses.first(where: { 43 | $0.r == coordinate.r && 44 | $0.c == coordinate.c && 45 | $0.s == coordinate.s 46 | })?.values ?? Set() 47 | } 48 | 49 | // MARK: - Private helpers 50 | 51 | private func values(in squareIndex: Int, grid: [CoordinateValue]) -> [CoordinateValue] { 52 | grid.filter { coordinateValue -> Bool in 53 | coordinateValue.s == squareIndex 54 | } 55 | } 56 | 57 | private func grid(_ grid: [CoordinateValue], containsAValueAt coordinate: Coordinate) -> Bool { 58 | let result = grid.contains { coordinateValue -> Bool in 59 | let gridCoordinate = Coordinate(r: coordinateValue.r, c: coordinateValue.c, s: coordinateValue.s) 60 | return gridCoordinate == coordinate 61 | } 62 | return result 63 | } 64 | 65 | private func coordinateAt(_ columnIndex: Int, isInSameColumnAs selectedCoordinate: Coordinate) -> Bool { 66 | let leftSquareIndices = [0, 3, 6] 67 | let midSquareIndices = [1, 4, 7] 68 | let rightSquareIndices = [2, 5, 8] 69 | 70 | var isInSameColumn = false 71 | 72 | if leftSquareIndices.contains(squareIndex) && leftSquareIndices.contains(selectedCoordinate.s) && columnIndex == selectedCoordinate.c || 73 | midSquareIndices.contains(squareIndex) && midSquareIndices.contains(selectedCoordinate.s) && columnIndex == selectedCoordinate.c || 74 | rightSquareIndices.contains(squareIndex) && rightSquareIndices.contains(selectedCoordinate.s) && columnIndex == selectedCoordinate.c { 75 | isInSameColumn = true 76 | } 77 | 78 | return isInSameColumn 79 | } 80 | 81 | private func coordinateAt(_ columnIndex: Int, isInSameRowAs selectedCoordinate: Coordinate) -> Bool { 82 | let topSquareRowIndices = [0, 1, 2] 83 | let midSquareRowIndices = [3, 4, 5] 84 | let bottomSquareRowIndices = [6, 7, 8] 85 | 86 | var isInSameRow = false 87 | 88 | if topSquareRowIndices.contains(squareIndex) && topSquareRowIndices.contains(selectedCoordinate.s) && index == selectedCoordinate.r || 89 | midSquareRowIndices.contains(squareIndex) && midSquareRowIndices.contains(selectedCoordinate.s) && index == selectedCoordinate.r || 90 | bottomSquareRowIndices.contains(squareIndex) && bottomSquareRowIndices.contains(selectedCoordinate.s) && index == selectedCoordinate.r { 91 | isInSameRow = true 92 | } 93 | 94 | return isInSameRow 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Sudoku/Game/Grid/Square.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Square.swift 3 | // Sudoku 4 | // 5 | // Created by Raymond Kim on 6/2/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct Square: View { 12 | 13 | let index: Int 14 | let editGridSlice: [CoordinateEditValues] 15 | var selectedCell: SelectedCell 16 | var userAction: UserAction 17 | let workingGrid: GridValues 18 | 19 | private let viewModel = SquareViewModel() 20 | 21 | var body: some View { 22 | Grid(alignment: .leading, horizontalSpacing: 0, verticalSpacing: 0) { 23 | ForEach(viewModel.rowIndices, id: \.self) { rowIndex in 24 | Row(selectedCell: selectedCell, 25 | userAction: userAction, 26 | workingGrid: workingGrid, 27 | viewModel: RowViewModel(index: rowIndex, 28 | squareIndex: self.index, 29 | guesses: self.editGridSlice.filter { $0.r == rowIndex })) 30 | } 31 | } 32 | .border(Color.black, width: viewModel.borderWidth) 33 | } 34 | } 35 | 36 | #Preview { 37 | GeometryReader { geometry in 38 | Square(index: 0, 39 | editGridSlice: [], 40 | selectedCell: SelectedCell(), 41 | userAction: UserAction(), 42 | workingGrid: GridValues(startingGrid: GridFactory.easyGrid)) 43 | .environment(WindowSize(size: geometry.size)) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sudoku/Game/Grid/SquareViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SquareViewModel.swift 3 | // Sudoku 4 | // 5 | // Created by Ray Kim on 7/2/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct SquareViewModel: ViewModel { 12 | 13 | let rowIndices = [0, 1, 2] 14 | var borderWidth: CGFloat = 2 15 | } 16 | -------------------------------------------------------------------------------- /Sudoku/Game/Grid/SudokuGrid.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SudokuGrid.swift 3 | // Sudoku 4 | // 5 | // Created by Raymond Kim on 6/2/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct SudokuGrid: View { 12 | 13 | var selectedCell: SelectedCell 14 | var userAction: UserAction 15 | let editGrid: [CoordinateEditValues] 16 | let workingGrid: GridValues 17 | 18 | /// This width value determines how much space there is padded on the sides of 19 | /// the sudoku grid. The grid will resize and scale accordingly. 20 | static var spacerWidth: CGFloat { 21 | if UIDevice.current.userInterfaceIdiom == .phone { 22 | return 8 23 | } else { 24 | return 160 25 | } 26 | } 27 | 28 | private let borderWidth: CGFloat = 2 29 | private let squareRowRanges = [(0...2), (3...5), (6...8)] 30 | 31 | var body: some View { 32 | GeometryReader { geometry in 33 | GridContainerView { squareIndex in 34 | Square(index: squareIndex, 35 | editGridSlice: self.editGrid.filter { $0.s == squareIndex }, 36 | selectedCell: selectedCell, 37 | userAction: userAction, 38 | workingGrid: workingGrid) 39 | } 40 | .padding(.horizontal, getSpacerWidth(screenSize: geometry.size)) 41 | } 42 | } 43 | 44 | private func getSpacerWidth(screenSize: CGSize) -> CGFloat { 45 | if isIphone { 46 | return 8 47 | } else if isVision { 48 | // maintain square aspect ratio 49 | return abs(screenSize.width - screenSize.height) / 2 50 | } else { 51 | return 160 52 | } 53 | } 54 | } 55 | 56 | #Preview { 57 | GeometryReader { geometry in 58 | SudokuGrid(selectedCell: SelectedCell(), 59 | userAction: UserAction(), 60 | editGrid: [], 61 | workingGrid: GridValues.init(startingGrid: GridFactory.easyGrid)) 62 | .environment(WindowSize(size: geometry.size)) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sudoku/Game/HintButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HintButton.swift 3 | // Sudoku 4 | // 5 | // Created by Ray Kim on 12/22/23. 6 | // Copyright © 2023 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct HintButton: View { 12 | 13 | enum LoadingState: Equatable { 14 | case idle, loading, loaded(message: String), error(_ error: API.APIError?) 15 | 16 | var rawValue: String { 17 | switch self { 18 | case .idle: return "" 19 | case .loading: return "Generating hint..." 20 | case .loaded(message: let message): return message 21 | case .error(let errorType): 22 | if case .quotaExceeded = errorType { 23 | return AlertItem.hintErrorQuota.message 24 | } 25 | return AlertItem.hintError.message 26 | } 27 | } 28 | } 29 | 30 | /// Note: used only for visionOS 31 | @Binding var alertItem: AlertItem? 32 | /// Note: used only for visionOS 33 | @Binding var alertIsPresented: Bool 34 | 35 | /// Note: used for iOS and iPadOS 36 | @State private var showingHintSheet = false 37 | /// Note: used for iOS and iPadOS 38 | @State private var hintState: LoadingState = .idle 39 | 40 | private static var hintCache: [String: Hint] = [:] 41 | static func clearCache() { 42 | hintCache.removeAll() 43 | } 44 | 45 | let grid: [CoordinateValue] 46 | let difficulty: Difficulty.Level 47 | 48 | private var placeholderText: String { 49 | let text = 50 | """ 51 | placeholder text placeholder text placeholder text placeholder text 52 | placeholder text placeholder text placeholder text placeholder text 53 | placeholder text placeholder text placeholder text placeholder text 54 | placeholder text placeholder text placeholder text placeholder text 55 | placeholder text placeholder text placeholder text placeholder text 56 | """ 57 | 58 | return text 59 | } 60 | 61 | private var textView: some View { 62 | Text(hintState.rawValue) 63 | .font(.system(.body, design: .rounded)) 64 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) 65 | } 66 | 67 | private var placeholderView: some View { 68 | Text(placeholderText) 69 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) 70 | .redacted(reason: .placeholder) 71 | .shimmer() 72 | } 73 | 74 | var body: some View { 75 | Button("Hint", systemImage: "lightbulb.max") { 76 | Task { 77 | await getHint() 78 | } 79 | } 80 | .tint(.primary) 81 | .symbolEffect(.pulse, value: hintState == .loading) 82 | .disabled(hintState == .loading) 83 | .sheet(isPresented: $showingHintSheet) { 84 | Group { 85 | if hintState == .loading { 86 | placeholderView 87 | } else { 88 | textView 89 | } 90 | } 91 | .padding(16) 92 | .transition(.opacity) 93 | .presentationDetents([.medium, .height(170)]) 94 | .presentationDragIndicator(.visible) 95 | .animation(.easeInOut, value: hintState) 96 | } 97 | } 98 | 99 | private func updateOnSuccess(_ hint: Hint) { 100 | hintState = .loaded(message: hint.description) 101 | 102 | if isVision { 103 | alertItem = .hintSuccess(hint: hint.description) 104 | alertIsPresented = true 105 | } 106 | } 107 | 108 | private func updateOnError(_ error: API.APIError?) { 109 | hintState = .error(error) 110 | 111 | if isVision { 112 | alertItem = .hintError 113 | alertIsPresented = true 114 | } 115 | } 116 | 117 | private func updateWith(cachedHint: Hint) { 118 | var hintDescription = cachedHint.description 119 | 120 | switch cachedHint.hintType { 121 | case .nakedSingle: 122 | if let value = cachedHint.coordinate?.v { 123 | hintDescription = "Somewhere on the board, there is a naked single \(value). Can you find it?" 124 | } 125 | case .hiddenSingle: 126 | if let value = cachedHint.coordinate?.v { 127 | hintDescription = "Somewhere on the board, there is a hidden single \(value). Can you find it?" 128 | } 129 | default: 130 | break 131 | } 132 | 133 | let hint = Hint(coordinate: cachedHint.coordinate, hintType: cachedHint.hintType, overrideDescription: hintDescription) 134 | updateOnSuccess(hint) 135 | } 136 | 137 | private func getHint() async { 138 | do { 139 | showingHintSheet = !isVision 140 | hintState = .loading 141 | 142 | let cacheKey = grid.map { coordinate in String(coordinate.v) }.joined() 143 | 144 | if let cachedHint = Self.hintCache[cacheKey] { 145 | updateWith(cachedHint: cachedHint) 146 | } else if let hint = try await API.getHint(grid: grid, difficulty: difficulty) { 147 | Self.hintCache[cacheKey] = hint 148 | updateOnSuccess(hint) 149 | } else { 150 | updateOnError(nil) 151 | } 152 | } catch { 153 | updateOnError(error as? API.APIError) 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /Sudoku/Game/KeysRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeysRow.swift 3 | // Sudoku 4 | // 5 | // Created by Raymond Kim on 6/2/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct KeysRow: View { 12 | 13 | var editGrid: EditGridValues 14 | var userAction: UserAction 15 | var workingGrid: GridValues 16 | 17 | @Binding var alert: AlertItem? 18 | @Binding var alertIsPresented: Bool 19 | 20 | let selectedCoordinate: Coordinate? 21 | let isEditing: Bool 22 | 23 | @Binding var savedState: SavedState 24 | 25 | var undoManager: UndoManager 26 | 27 | private let buttonCornerRadius: CGFloat = 5 28 | private var horizontalSpacing: CGFloat { 29 | if isVision { 30 | return 32 31 | } else if isIpad { 32 | return 80 33 | } else { 34 | return 8 35 | } 36 | } 37 | 38 | var body: some View { 39 | HStack(spacing: 0) { 40 | Spacer() 41 | .frame(width: horizontalSpacing) 42 | HStack(spacing: 2) { 43 | ForEach((1...9), id: \.self) { digit in 44 | Button(action: { 45 | self.updateForDigit(digit) 46 | }) { 47 | KeysRowButtonText(text: "\(digit)") 48 | } 49 | .buttonStyle(.plain) 50 | .background(Color("dynamicGray")) 51 | .clipShape(clipShape) 52 | } 53 | } 54 | .frame(maxWidth: .infinity) 55 | Spacer() 56 | .frame(width: horizontalSpacing) 57 | } 58 | } 59 | 60 | private var clipShape: AnyShape { 61 | if isVision { 62 | AnyShape(Capsule()) 63 | } else { 64 | AnyShape(RoundedRectangle(cornerRadius: buttonCornerRadius)) 65 | } 66 | } 67 | 68 | private func updateForDigit(_ digit: Int) { 69 | guard let selectedCoordinate = selectedCoordinate, 70 | !workingGrid.containsAValue(at: selectedCoordinate, grid: workingGrid.startingGrid) else { 71 | return 72 | } 73 | 74 | if isEditing { 75 | // For edit mode, track each individual pencil mark change 76 | let currentGuesses = editGrid.guesses(for: selectedCoordinate)?.values ?? Set() 77 | if currentGuesses.contains(digit) { 78 | // Removing a pencil mark 79 | trackUndoAction(at: selectedCoordinate, action: .remove(digit: digit)) 80 | } else { 81 | // Adding a pencil mark 82 | trackUndoAction(at: selectedCoordinate, action: .add(digit: digit)) 83 | } 84 | editGrid.updateGuesses(value: digit, at: selectedCoordinate) 85 | savedState = .unsaved 86 | userAction.action = .digit(digit) 87 | } else { 88 | // For normal mode, only track if we're changing the value 89 | let currentValue = workingGrid.getValue(at: selectedCoordinate, grid: workingGrid.grid) 90 | if currentValue != digit { 91 | trackUndoAction(at: selectedCoordinate, action: .none) 92 | editGrid.removeValues(at: selectedCoordinate) 93 | 94 | let coordinateValue = CoordinateValue(r: selectedCoordinate.r, 95 | c: selectedCoordinate.c, 96 | s: selectedCoordinate.s, 97 | v: digit) 98 | workingGrid.add(coordinateValue) 99 | savedState = .unsaved 100 | userAction.action = .digit(digit) 101 | 102 | if workingGrid.grid.count == 81 { 103 | if workingGrid.isSolved { 104 | alert = .completedCorrectly 105 | alertIsPresented = true 106 | } else { 107 | alert = .completedIncorrectly 108 | alertIsPresented = true 109 | } 110 | } 111 | } 112 | } 113 | } 114 | 115 | private func trackUndoAction(at coordinate: Coordinate, action: EditActionType) { 116 | let previousValue = workingGrid.getValue(at: coordinate, grid: workingGrid.grid) 117 | let previousEditValues = editGrid.guesses(for: coordinate)?.values ?? Set() 118 | 119 | undoManager.addAction(UndoAction( 120 | coordinate: coordinate, 121 | previousValue: previousValue, 122 | previousEditValues: previousEditValues, 123 | wasEditing: isEditing, 124 | editActionType: action 125 | )) 126 | } 127 | } 128 | 129 | #Preview { 130 | KeysRow(editGrid: EditGridValues(grid: []), 131 | userAction: UserAction(), 132 | workingGrid: GridValues(startingGrid: GridFactory.easyGrid), 133 | alert: .constant(.completedCorrectly), 134 | alertIsPresented: .constant(false), 135 | selectedCoordinate: nil, 136 | isEditing: false, 137 | savedState: .constant(.unsaved), 138 | undoManager: UndoManager()) 139 | } 140 | -------------------------------------------------------------------------------- /Sudoku/Game/KeysRowButtonText.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeysRowButtonText.swift 3 | // Sudoku 4 | // 5 | // Created by Raymond Kim on 6/4/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct KeysRowButtonText: View { 12 | 13 | let text: String 14 | 15 | private var textHeight: CGFloat { 16 | isIpad ? 72 : 48 17 | } 18 | private var textFont: Font { 19 | let textStyle: Font.TextStyle = isIpad ? .largeTitle : .title 20 | return Font.system(textStyle, design: .rounded) 21 | } 22 | 23 | var body: some View { 24 | Text(text) 25 | .foregroundColor(.black) 26 | .font(textFont) 27 | .frame(maxWidth: .infinity, minHeight: textHeight) 28 | } 29 | } 30 | 31 | #Preview { 32 | KeysRowButtonText(text: "1") 33 | } 34 | -------------------------------------------------------------------------------- /Sudoku/Game/NewGameButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewGameButton.swift 3 | // Sudoku 4 | // 5 | // Created by Ray Kim on 7/1/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct NewGameButton: View { 12 | 13 | @Environment(\.dismiss) private var dismiss 14 | 15 | @Binding var alert: AlertItem? 16 | @Binding var alertIsPresented: Bool 17 | 18 | let hasUpdatedGrid: Bool 19 | let savedState: SavedState 20 | 21 | var body: some View { 22 | Button(action: { 23 | if hasUpdatedGrid && savedState == .unsaved { 24 | self.alert = .newGame 25 | self.alertIsPresented = true 26 | } else { 27 | dismiss() 28 | } 29 | }) { 30 | Text("New game") 31 | .font(.system(.headline, design: .rounded)) 32 | } 33 | .dynamicButtonStyle(backgroundColor: Color.blue.opacity(0.2)) 34 | } 35 | } 36 | 37 | struct NewGameButton_Previews: PreviewProvider { 38 | static var previews: some View { 39 | NewGameButton(alert: .constant(.newGame), 40 | alertIsPresented: .constant(false), 41 | hasUpdatedGrid: true, 42 | savedState: .saved) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sudoku/How to Play/HowToPlayView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HowToPlayView.swift 3 | // Sudoku 4 | // 5 | // Created by Ray Kim on 3/12/24. 6 | // Copyright © 2024 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct HowToPlayView: View { 12 | 13 | /// Note: For iPad and iPhone, the return value must match `StaticGridView.spacerWidth`. 14 | private var horizontalContainerPadding: CGFloat { 15 | if isVision { 16 | return 300 17 | } else if isIpad { 18 | return 140 19 | } else { 20 | return 4 21 | } 22 | } 23 | 24 | private var horizontalTextPadding: CGFloat { 25 | isIphone ? 12 : 0 26 | } 27 | 28 | /// Used for additional grid padding on visionOS. 29 | private var horizontalGridPadding: CGFloat { 30 | isVision ? StaticGridView.spacerWidth - horizontalContainerPadding : 0 31 | } 32 | 33 | var body: some View { 34 | GeometryReader { geometry in 35 | ScrollView { 36 | VStack(alignment: .leading, spacing: 24) { 37 | VStack(alignment: .leading, spacing: 12) { 38 | Text("Sudoku is a puzzle where you fill all the individual digit squares inside a grid with the right digit. But what determines the right digit? There are a few requirements that must be met:") 39 | Text("First, every row in the grid must include the numbers 1 to 9. Each digit can only appear once.") 40 | } 41 | .padding(.horizontal, horizontalTextPadding) 42 | StaticGridView(highlightSection: .row, grid: GridFactory.easyGrid) 43 | .padding(.horizontal, horizontalGridPadding) 44 | Text("Second, every column in the grid must also include the numbers 1 to 9 exactly once.") 45 | .padding(.horizontal, horizontalTextPadding) 46 | StaticGridView(highlightSection: .column, grid: GridFactory.easyGrid) 47 | .padding(.horizontal, horizontalGridPadding) 48 | Text("You'll notice that within the grid there are nine squares shown with bold borders. Below is one square highlighted in yellow. The third requirement is that each of these squares must also only contain the numbers 1 through 9, once per digit.") 49 | .padding(.horizontal, horizontalTextPadding) 50 | StaticGridView(highlightSection: .square, grid: GridFactory.easyGrid) 51 | .padding(.horizontal, horizontalGridPadding) 52 | VStack(alignment: .leading, spacing: 12) { 53 | Text("If all three conditions are met, then the Sudoku grid has been solved.") 54 | Text("The difficulty of a Sudoku puzzle can range depending on how many digits are already filled in at the beginning. Good luck and have fun solving them!") 55 | } 56 | .padding(.horizontal, horizontalTextPadding) 57 | } 58 | .padding(.top, 8) 59 | .padding(.bottom, 24) 60 | .padding(.horizontal, horizontalContainerPadding) 61 | } 62 | .font(Font.system(.headline, design: .rounded)) 63 | .fullBackgroundStyle() 64 | .navigationTitle("How to play") 65 | .navigationBarTitleDisplayMode(.large) 66 | } 67 | } 68 | } 69 | 70 | #Preview { 71 | GeometryReader { geometry in 72 | HowToPlayView() 73 | .environment(WindowSize(size: geometry.size)) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Sudoku/How to Play/StaticGridView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StaticGridView.swift 3 | // Sudoku 4 | // 5 | // Created by Ray Kim on 3/12/24. 6 | // Copyright © 2024 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | enum HighlightSection { 12 | case row, column, square 13 | } 14 | 15 | struct StaticGridView: View { 16 | 17 | static var spacerWidth: CGFloat { 18 | if UIDevice.current.userInterfaceIdiom == .phone { 19 | return 4 20 | } else if UIDevice.current.userInterfaceIdiom == .pad { 21 | return 140 22 | } else { 23 | return 400 24 | } 25 | } 26 | 27 | let highlightSection: HighlightSection 28 | let grid: [CoordinateValue] 29 | 30 | private let squareRowRanges = [(0...2), (3...5), (6...8)] 31 | private let borderWidth: CGFloat = 2 32 | 33 | var body: some View { 34 | GridContainerView { squareIndex in 35 | StaticSquareView(index: squareIndex, highlightSection: highlightSection, grid: grid) 36 | .background(getBackgroundColor(squareIndex: squareIndex, highlightSection: highlightSection)) 37 | } 38 | } 39 | 40 | private func getBackgroundColor(squareIndex: Int, highlightSection: HighlightSection) -> Color { 41 | if squareIndex == 0 && highlightSection == .square { 42 | return .yellow 43 | } else { 44 | return Color("dynamicGridWhite") 45 | } 46 | } 47 | } 48 | 49 | #Preview { 50 | GeometryReader { geometry in 51 | StaticGridView(highlightSection: .column, grid: GridFactory.easyGrid) 52 | .environment(WindowSize(size: geometry.size)) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sudoku/How to Play/StaticRowView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StaticRowView.swift 3 | // Sudoku 4 | // 5 | // Created by Ray Kim on 3/12/24. 6 | // Copyright © 2024 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct StaticRowView: View { 12 | 13 | let rowIndex: Int 14 | let squareIndex: Int 15 | let highlightSection: HighlightSection 16 | let grid: [CoordinateValue] 17 | 18 | private var isValidSquare: Bool { 19 | return squareIndex == 0 || squareIndex == 3 || squareIndex == 6 20 | } 21 | 22 | var body: some View { 23 | GridRow { 24 | ForEach(0..<3) { colIndex in 25 | let digit = grid.first(where: { $0.r == rowIndex && $0.c == colIndex && $0.s == squareIndex })?.v ?? -1 26 | let digitText = digit == -1 ? " " : "\(digit)" 27 | RowButtonText(text: digitText, foregroundColor: .black, isStatic: true) 28 | .padding(.horizontal, 6) 29 | .frame(maxWidth: .infinity) 30 | .background(getBackgroundColor(colIndex: colIndex, isValidSquare: isValidSquare, highlightSection: highlightSection)) 31 | .border(.black, width: 1) 32 | } 33 | } 34 | } 35 | 36 | private func getBackgroundColor(colIndex: Int, isValidSquare: Bool, highlightSection: HighlightSection) -> Color { 37 | if colIndex == 0 && isValidSquare && highlightSection == .column { 38 | return .yellow 39 | } else { 40 | return .clear 41 | } 42 | } 43 | } 44 | 45 | #Preview { 46 | GeometryReader { geometry in 47 | StaticRowView(rowIndex: 0, squareIndex: 0, highlightSection: .column, grid: GridFactory.easyGrid) 48 | .environment(WindowSize(size: geometry.size)) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sudoku/How to Play/StaticSquareView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StaticSquareView.swift 3 | // Sudoku 4 | // 5 | // Created by Ray Kim on 3/12/24. 6 | // Copyright © 2024 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct StaticSquareView: View { 12 | 13 | let index: Int 14 | let highlightSection: HighlightSection 15 | let grid: [CoordinateValue] 16 | 17 | private let borderWidth: CGFloat = 2 18 | 19 | private var isValidSquare: Bool { 20 | return index == 0 || index == 1 || index == 2 21 | } 22 | 23 | var body: some View { 24 | Grid(horizontalSpacing: 0, verticalSpacing: 0) { 25 | ForEach(0..<3) { rowIndex in 26 | StaticRowView(rowIndex: rowIndex, squareIndex: index, highlightSection: highlightSection, grid: grid) 27 | .background(getBackgroundColor(rowIndex: rowIndex, isValidSquare: isValidSquare, highlightSection: highlightSection)) 28 | } 29 | } 30 | .border(.black, width: borderWidth) 31 | } 32 | 33 | private func getBackgroundColor(rowIndex: Int, isValidSquare: Bool, highlightSection: HighlightSection) -> Color { 34 | if rowIndex == 0 && isValidSquare && highlightSection == .row { 35 | return .yellow 36 | } else { 37 | return .clear 38 | } 39 | } 40 | } 41 | 42 | #Preview { 43 | GeometryReader { geometry in 44 | VStack { 45 | StaticSquareView(index: 0, highlightSection: .square, grid: GridFactory.easyGrid) 46 | .environment(WindowSize(size: geometry.size)) 47 | StaticSquareView(index: 1, highlightSection: .row, grid: GridFactory.easyGrid) 48 | .environment(WindowSize(size: geometry.size)) 49 | StaticSquareView(index: 2, highlightSection: .column, grid: GridFactory.easyGrid) 50 | .environment(WindowSize(size: geometry.size)) 51 | } 52 | .padding(.horizontal, 132) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sudoku/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 4.12.0 19 | CFBundleVersion 20 | 30 21 | ITSAppUsesNonExemptEncryption 22 | 23 | LSRequiresIPhoneOS 24 | 25 | UIApplicationSceneManifest 26 | 27 | UIApplicationSupportsMultipleScenes 28 | 29 | UISceneConfigurations 30 | 31 | 32 | UILaunchStoryboardName 33 | LaunchScreen 34 | UIMainStoryboardFile 35 | LaunchScreen 36 | UIRequiredDeviceCapabilities 37 | 38 | armv7 39 | 40 | UIRequiresFullScreen 41 | 42 | UISupportedInterfaceOrientations 43 | 44 | UIInterfaceOrientationPortrait 45 | UIInterfaceOrientationPortraitUpsideDown 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /Sudoku/Menu/GameLevelButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GameLevelButton.swift 3 | // Sudoku 4 | // 5 | // Created by Ray Kim on 7/12/24. 6 | // Copyright © 2024 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct GameLevelButton: View { 12 | 13 | @Binding var gameConfigs: [GameConfig] 14 | let level: Difficulty.Level 15 | 16 | var body: some View { 17 | Button(action: { 18 | let startingGrid = GridFactory.randomGridForDifficulty(level: level) 19 | let gameConfig = GameConfig(savedState: .startedUnsaved, 20 | workingGrid: startingGrid, 21 | startingGrid: startingGrid, 22 | difficulty: level, 23 | elapsedTime: 0) 24 | gameConfigs.append(gameConfig) 25 | }) { 26 | Text(level.rawValue) 27 | .font(.system(.headline, design: .rounded)) 28 | } 29 | .dynamicButtonStyle(backgroundColor: Color.blue.opacity(0.2)) 30 | } 31 | } 32 | 33 | #Preview { 34 | GameLevelButton(gameConfigs: .constant([]), level: .easy) 35 | } 36 | -------------------------------------------------------------------------------- /Sudoku/Menu/MenuNavigationLinks.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MenuNavigationLinks.swift 3 | // Sudoku 4 | // 5 | // Created by Ray Kim on 10/19/24. 6 | // Copyright © 2024 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct MenuNavigationLinks: View { 12 | 13 | var body: some View { 14 | #if os(visionOS) 15 | HStack(spacing: 18) { 16 | navigationLinks 17 | } 18 | #else 19 | VStack(spacing: 18) { 20 | navigationLinks 21 | } 22 | #endif 23 | } 24 | 25 | private var navigationLinks: some View { 26 | Group { 27 | NavigationLink { 28 | StatsView() 29 | } label: { 30 | Label("Stats", systemImage: "chart.bar") 31 | .font(.system(.headline, design: .rounded)) 32 | .tint(Color("dynamicDarkGray")) 33 | } 34 | NavigationLink { 35 | HowToPlayView() 36 | } label: { 37 | Label("How to play", systemImage: "questionmark.circle") 38 | .font(.system(.headline, design: .rounded)) 39 | .tint(Color("dynamicDarkGray")) 40 | } 41 | NavigationLink { 42 | SettingsView() 43 | } label: { 44 | Label("Settings", systemImage: "gear") 45 | .font(.system(.headline, design: .rounded)) 46 | .tint(Color("dynamicDarkGray")) 47 | } 48 | } 49 | } 50 | } 51 | 52 | #Preview { 53 | MenuNavigationLinks() 54 | } 55 | -------------------------------------------------------------------------------- /Sudoku/Menu/MenuView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MenuView.swift 3 | // Sudoku 4 | // 5 | // Created by Raymond Kim on 6/2/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import SwiftData 11 | 12 | struct MenuView: View { 13 | 14 | private let viewModel = MenuViewModel() 15 | @Query var savedGameState: [GameConfig] 16 | @State var gameConfigs: [GameConfig] = [] 17 | 18 | var body: some View { 19 | NavigationStack(path: $gameConfigs) { 20 | VStack(spacing: viewModel.buttonsVSpacing) { 21 | Text("Sudoku AI") 22 | .font(.system(.largeTitle, design: .rounded)) 23 | .bold() 24 | VStack(spacing: viewModel.savedGameVSpacing) { 25 | if let savedGameState = savedGameState.first { 26 | NavigationLink(value: savedGameState) { 27 | Text("Continue game") 28 | .font(.system(.headline, design: .rounded)) 29 | } 30 | .dynamicButtonStyle(backgroundColor: Color.blue.opacity(0.2)) 31 | } 32 | HStack(spacing: viewModel.difficultyButtonHSpacing) { 33 | ForEach(viewModel.difficultyLevels, id: \.self) { level in 34 | GameLevelButton(gameConfigs: $gameConfigs, level: level) 35 | } 36 | } 37 | } 38 | MenuNavigationLinks() 39 | } 40 | .fullBackgroundStyle() 41 | .navigationDestination(for: GameConfig.self) { gameConfig in 42 | GameView(gameConfig) 43 | } 44 | } 45 | } 46 | } 47 | 48 | #Preview { 49 | GeometryReader { geometry in 50 | MenuView() 51 | .environment(WindowSize(size: geometry.size)) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sudoku/Menu/MenuViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MenuViewModel.swift 3 | // Sudoku 4 | // 5 | // Created by Ray Kim on 7/2/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct MenuViewModel: ViewModel { 12 | 13 | var savedGameVSpacing: CGFloat { 14 | 16 * (isIpad ? 2.5 : 1) 15 | } 16 | 17 | var difficultyButtonHSpacing: CGFloat { 18 | 16 * (isIpad ? 2.5 : 1) 19 | } 20 | 21 | var buttonsVSpacing: CGFloat { 22 | 40 * (isIpad ? 2 : 1) 23 | } 24 | 25 | let difficultyLevels: [Difficulty.Level] = [.easy, .medium, .hard] 26 | } 27 | -------------------------------------------------------------------------------- /Sudoku/Models/AlertItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlertItem.swift 3 | // Sudoku 4 | // 5 | // Created by Ray Kim on 7/2/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum AlertItem: Equatable { 12 | case completedCorrectly 13 | case completedIncorrectly 14 | case hintSuccess(hint: String) 15 | case hintError 16 | case hintErrorQuota 17 | case newGame 18 | case overwriteWarning 19 | 20 | var title: String { 21 | switch self { 22 | case .completedCorrectly: 23 | return "Congratulations!" 24 | case .completedIncorrectly: 25 | return "Almost!" 26 | case .hintSuccess(_): 27 | return "Hint" 28 | case .hintError, .hintErrorQuota: 29 | return "Hint" 30 | case .newGame: 31 | return "Are you sure?" 32 | case .overwriteWarning: 33 | return "Heads up!" 34 | } 35 | } 36 | 37 | var message: String { 38 | switch self { 39 | case .completedCorrectly: 40 | return "You've completed the sudoku!" 41 | case .completedIncorrectly: 42 | return "Sorry but that's not quite right. Try and use the hint feature to help you." 43 | case .hintSuccess(let hint): 44 | return hint 45 | case .hintError: 46 | return "Oops! Something went wrong. Try again later." 47 | case .hintErrorQuota: 48 | return "Quota exceeded. Please try again later." 49 | case .newGame: 50 | return "If you go back without saving, you will lose your current progress." 51 | case .overwriteWarning: 52 | return "Looks like you already have a saved game. Do you want to overwrite with this game?" 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sudoku/Models/ChatResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChatResponse.swift 3 | // Sudoku 4 | // 5 | // Created by Ray Kim on 12/22/23. 6 | // Copyright © 2023 Self. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct ChatResponse: Codable { 12 | 13 | let id: String 14 | let choices: [ChatCompletionChoice] 15 | let created: Int 16 | let model: String 17 | let object: String 18 | let systemFingerprint: String? 19 | } 20 | 21 | struct ChatCompletionChoice: Codable { 22 | 23 | let finishReason: String 24 | let index: Int 25 | let message: ChatCompletionMessage 26 | } 27 | 28 | struct ChatCompletionMessage: Codable { 29 | 30 | let content: String? 31 | let role: String 32 | } 33 | 34 | -------------------------------------------------------------------------------- /Sudoku/Models/CoordinateColor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CoordinateColor.swift 3 | // Sudoku 4 | // 5 | // Created by Ray Kim on 6/29/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct CoordinateColor: Hashable, Codable { 12 | let coordinate: CoordinateValue 13 | let color: Color 14 | 15 | var s: Int { 16 | coordinate.s 17 | } 18 | 19 | var r: Int { 20 | coordinate.r 21 | } 22 | 23 | var c: Int { 24 | coordinate.c 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sudoku/Models/CoordinateEditValues.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CoordinateEditValues.swift 3 | // Sudoku 4 | // 5 | // Created by Raymond Kim on 6/15/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct CoordinateEditValues: Equatable, Codable { 12 | let r: Int 13 | let c: Int 14 | let s: Int 15 | var values: Set 16 | } 17 | -------------------------------------------------------------------------------- /Sudoku/Models/CoordinateValue.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CoordinateValue.swift 3 | // Sudoku 4 | // 5 | // Created by Raymond Kim on 6/12/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct CoordinateValue: Hashable, Codable { 12 | let r: Int 13 | let c: Int 14 | let s: Int 15 | let v: Int 16 | 17 | var coordinate: Coordinate { 18 | return Coordinate(r: r, c: c, s: s) 19 | } 20 | } 21 | 22 | struct Coordinate: Equatable, Codable { 23 | let r: Int 24 | let c: Int 25 | let s: Int 26 | } 27 | -------------------------------------------------------------------------------- /Sudoku/Models/Difficulty.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Difficulty.swift 3 | // Sudoku 4 | // 5 | // Created by Raymond Kim on 6/16/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | @Observable final class Difficulty { 12 | 13 | enum Level: String, Codable { 14 | case easy = "Easy" 15 | case medium = "Medium" 16 | case hard = "Hard" 17 | } 18 | 19 | var level: Level 20 | 21 | init(level: Level) { 22 | self.level = level 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sudoku/Models/EditGridValues.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EditGridValues.swift 3 | // Sudoku 4 | // 5 | // Created by Raymond Kim on 6/15/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | @Observable final class EditGridValues { 12 | /// Note: Only includes coordinates that contain more than 0 edited values. 13 | var grid: [CoordinateEditValues] 14 | 15 | init(grid: [CoordinateEditValues]) { 16 | self.grid = grid 17 | } 18 | 19 | var isEmpty: Bool { 20 | grid.isEmpty 21 | } 22 | 23 | // Note: this returns a copy because grid is an array of value types. 24 | func guesses(for coordinate: Coordinate) -> CoordinateEditValues? { 25 | return grid.first(where: { $0.r == coordinate.r && $0.c == coordinate.c && $0.s == coordinate.s }) 26 | } 27 | 28 | func updateGuesses(value: Int, at coordinate: Coordinate) { 29 | if let existingEditGrid = guesses(for: coordinate) { 30 | var set = existingEditGrid.values 31 | 32 | if set.contains(value) { 33 | set.remove(value) 34 | } else { 35 | set.insert(value) 36 | } 37 | 38 | grid.removeAll(where: { $0.c == coordinate.c && $0.r == coordinate.r && $0.s == coordinate.s }) 39 | let editValues = CoordinateEditValues(r: coordinate.r, c: coordinate.c, s: coordinate.s, values: set) 40 | grid.append(editValues) 41 | } else { 42 | var values = Set() 43 | values.insert(value) 44 | let editValues = CoordinateEditValues(r: coordinate.r, c: coordinate.c, s: coordinate.s, values: values) 45 | grid.append(editValues) 46 | } 47 | } 48 | 49 | func removeValues(at coordinate: Coordinate) { 50 | grid.removeAll(where: { $0.r == coordinate.r && $0.c == coordinate.c && $0.s == coordinate.s }) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sudoku/Models/EditState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EditState.swift 3 | // Sudoku 4 | // 5 | // Created by Raymond Kim on 6/12/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | @Observable final class EditState { 12 | var isEditing: Bool = false 13 | 14 | init(isEditing: Bool = false) { 15 | self.isEditing = isEditing 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sudoku/Models/GameConfig.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GameConfig.swift 3 | // Sudoku 4 | // 5 | // Created by Ray Kim on 7/3/24. 6 | // Copyright © 2024 Self. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftData 11 | 12 | @Model 13 | final class GameConfig { 14 | 15 | var savedState: SavedState 16 | var workingGrid: [CoordinateValue] 17 | var startingGrid: [CoordinateValue] 18 | var colorGrid: Set 19 | var userAction: UserAction.ActionType 20 | var selectedCell: Coordinate? 21 | var isEditing: Bool 22 | var editValues: [CoordinateEditValues] 23 | var difficulty: Difficulty.Level 24 | var elapsedTime: TimeInterval? 25 | 26 | init(savedState: SavedState, workingGrid: [CoordinateValue], startingGrid: [CoordinateValue], colorGrid: Set = [], userAction: UserAction.ActionType = .none, selectedCell: Coordinate? = nil, isEditing: Bool = false, editValues: [CoordinateEditValues] = [], difficulty: Difficulty.Level, elapsedTime: TimeInterval?) { 27 | self.savedState = savedState 28 | self.workingGrid = workingGrid 29 | self.startingGrid = startingGrid 30 | self.colorGrid = colorGrid 31 | self.userAction = userAction 32 | self.selectedCell = selectedCell 33 | self.isEditing = isEditing 34 | self.editValues = editValues 35 | self.difficulty = difficulty 36 | self.elapsedTime = elapsedTime 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sudoku/Models/GridFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GridFactory.swift 3 | // Sudoku 4 | // 5 | // Created by Raymond Kim on 6/18/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// (row, column, value)–square is implied by order of square arrays in array 12 | typealias ShortCoordinate = (Int, Int, Int) 13 | 14 | enum GridFactory { 15 | 16 | static func randomGridForDifficulty(level: Difficulty.Level) -> [CoordinateValue] { 17 | let randomNumber = Int.random(in: 0...2) 18 | switch level { 19 | case .easy: 20 | let easyGrids = [easyGrid, easyGrid1, easyGrid2] 21 | return easyGrids[randomNumber] 22 | // return GridFactory.superEasyGrid 23 | case .medium: 24 | let mediumGrids = [mediumGrid, mediumGrid1, mediumGrid2] 25 | return mediumGrids[randomNumber] 26 | case .hard: 27 | let hardGrids = [hardGrid, hardGrid1, hardGrid2] 28 | return hardGrids[randomNumber] 29 | } 30 | } 31 | 32 | // MARK: - grids 33 | 34 | /// FOR TESTING 35 | static var superEasyGrid: [CoordinateValue] { 36 | let square0 = [(0, 0, 9), (0, 1, 6), (0, 2, 2), (1, 0, 1), (1, 1, 8), (1, 2, 5), (2, 0, 3), (2, 1, 7), (2, 2, 4)] 37 | let square1 = [(0, 0, 3), (0, 1, 7), (0, 2, 8), (1, 0, 4), (1, 1, 2), (1, 2, 9), (2, 0, 5), (2, 1, 6), (2, 2, 1)] 38 | let square2 = [(0, 0, 4), (0, 1, 1), (0, 2, 5), (1, 0, 7), (1, 1, 6), (1, 2, 3), (2, 0, 9), (2, 1, 2), (2, 2, 8)] 39 | let square3 = [(0, 0, 4), (0, 1, 9), (0, 2, 6), (1, 0, 2), (1, 1, 1), (1, 2, 8), (2, 0, 7), (2, 1, 5), (2, 2, 3)] 40 | let square4 = [(0, 0, 8), (0, 1, 3), (0, 2, 2), (1, 0, 7), (1, 1, 4), (1, 2, 5), (2, 0, 1), (2, 1, 9), (2, 2, 6)] 41 | let square5 = [(0, 0, 1), (0, 1, 5), (0, 2, 7), (1, 1, 9), (1, 2, 6), (2, 0, 2), (2, 1, 8), (2, 2, 4)] 42 | let square6 = [(0, 0, 5), (0, 1, 3), (0, 2, 1), (1, 0, 8), (1, 1, 2), (1, 2, 7), (2, 0, 6), (2, 1, 4), (2, 2, 9)] 43 | let square7 = [(0, 0, 9), (0, 1, 8), (0, 2, 4), (1, 0, 6), (1, 1, 1), (1, 2, 3), (2, 0, 2), (2, 1, 5), (2, 2, 7)] 44 | let square8 = [(0, 0, 6), (0, 1, 7), (0, 2, 2), (1, 0, 5), (1, 1, 4), (1, 2, 9), (2, 1, 3), (2, 2, 1)] 45 | let squares = [square0, square1, square2, square3, square4, square5, square6, square7, square8] 46 | return GridFactory.convertToCoordinateValues(squares: squares) 47 | } 48 | 49 | static var easyGrid: [CoordinateValue] { 50 | let square0 = [(0, 0, 8), (1, 1, 9), (2, 0, 5), (2, 1, 7), (2, 2, 1)] 51 | let square1 = [(0, 0, 9), (0, 1, 5), (1, 0, 2), (1, 2, 8), (2, 0, 3), (2, 2, 6)] 52 | let square2 = [(0, 0, 4), (0, 1, 6), (0, 2, 1), (1, 0, 5), (1, 1, 3), (1, 2, 7), (2, 1, 9)] 53 | let square3 = [(0, 0, 1), (1, 1, 6), (1, 2, 2), (2, 0, 9), (2, 2, 5)] 54 | let square4 = [(0, 0, 6), (1, 0, 5), (1, 2, 9), (2, 1, 7), (2, 2, 1)] 55 | let square5 = [(0, 0, 9), (1, 1, 7), (2, 2, 3)] 56 | let square6 = [(0, 0, 2), (0, 2, 8), (1, 2, 4), (2, 0, 7), (2, 1, 3)] 57 | let square7 = [(0, 0, 7), (0, 2, 5), (1, 0, 8), (1, 2, 3), (2, 0, 1), (2, 2, 4)] 58 | let square8 = [(0, 0, 3), (0, 2, 6), (1, 1, 1), (2, 1, 5), (2, 2, 2)] 59 | let squares = [square0, square1, square2, square3, square4, square5, square6, square7, square8] 60 | return GridFactory.convertToCoordinateValues(squares: squares) 61 | } 62 | 63 | static var easyGrid1: [CoordinateValue] { 64 | let square0 = [(0, 1, 6), (1, 0, 1), (1, 1, 8), (1, 2, 5)] 65 | let square1 = [(0, 0, 3), (1, 1, 2), (2, 0, 5)] 66 | let square2 = [(0, 0, 4), (0, 1, 1), (1, 0, 7), (1, 2, 3), (2, 0, 9), (2, 1, 2), (2, 2, 8)] 67 | let square3 = [(0, 1, 9), (0, 2, 6), (1, 0, 2), (1, 1, 1), (2, 1, 5)] 68 | let square4 = [(0, 0, 8), (0, 2, 2), (1, 1, 4), (2, 2, 6)] 69 | let square5 = [(0, 1, 5), (0, 2, 7), (1, 0, 3), (2, 1, 8), (2, 2, 4)] 70 | let square6 = [(0, 0, 5), (2, 2, 9)] 71 | let square7 = [(0, 2, 4), (1, 0, 6), (1, 1, 1), (1, 2, 3), (2, 2, 7)] 72 | let square8 = [(0, 0, 6), (1, 0, 5), (1, 1, 4)] 73 | let squares = [square0, square1, square2, square3, square4, square5, square6, square7, square8] 74 | return GridFactory.convertToCoordinateValues(squares: squares) 75 | } 76 | 77 | static var easyGrid2: [CoordinateValue] { 78 | let square0 = [(0, 0, 6), (1, 0, 7), (1, 2, 4), (2, 0, 2), (2, 1, 1)] 79 | let square1 = [(0, 0, 4), (0, 1, 7), (1, 1, 6), (1, 2, 2), (2, 0, 5)] 80 | let square2 = [(0, 0, 3), (0, 2, 1), (1, 1, 8), (1, 2, 5), (2, 2, 7)] 81 | let square3 = [(0, 1, 2), (0, 2, 6), (1, 2, 3), (2, 0, 8), (2, 2, 1)] 82 | let square4 = [(0, 0, 3), (0, 1, 4), (1, 1, 9), (1, 2, 1)] 83 | let square5 = [(1, 0, 5), (1, 2, 2), (2, 0, 4)] 84 | let square6 = [(0, 0, 5), (0, 2, 8), (1, 1, 4), (2, 0, 1), (2, 1, 9)] 85 | let square7 = [(0, 1, 1), (1, 0, 6), (2, 0, 2)] 86 | let square8 = [(1, 2, 9), (2, 0, 8), (2, 1, 3)] 87 | let squares = [square0, square1, square2, square3, square4, square5, square6, square7, square8] 88 | return GridFactory.convertToCoordinateValues(squares: squares) 89 | } 90 | 91 | static var mediumGrid: [CoordinateValue] { 92 | let square0 = [(0, 0, 1), (0, 2, 2), (1, 0, 3), (1, 1, 9)] 93 | let square1 = [(0, 0, 8), (2, 1, 1)] 94 | let square2 = [(0, 2, 6), (1, 0, 8), (2, 2, 3)] 95 | let square3 = [(0, 2, 7), (1, 1, 6)] 96 | let square4 = [(0, 0, 1), (0, 2, 3), (1, 0, 2), (1, 1, 7), (1, 2, 4), (2, 0, 6), (2, 2, 5)] 97 | let square5 = [(1, 1, 5), (2, 0, 1)] 98 | let square6 = [(0, 0, 6), (1, 2, 5), (2, 0, 4)] 99 | let square7 = [(0, 1, 4), (2, 2, 1)] 100 | let square8 = [(1, 1, 2), (1, 2, 1), (2, 0, 6), (2, 2, 7)] 101 | let squares = [square0, square1, square2, square3, square4, square5, square6, square7, square8] 102 | return GridFactory.convertToCoordinateValues(squares: squares) 103 | } 104 | 105 | static var mediumGrid1: [CoordinateValue] { 106 | let square0 = [(0, 2, 4), (1, 0, 7), (1, 2, 2), (2, 0, 8)] 107 | let square1 = [(0, 1, 7), (1, 0, 9), (1, 1, 4), (1, 2, 3), (2, 2, 2)] 108 | let square2 = [(0, 0, 2), (0, 2, 6)] 109 | let square3 = [(1, 1, 2), (2, 1, 9)] 110 | let square4 = [(1, 2, 5), (2, 1, 3)] 111 | let square5 = [(1, 0, 3), (2, 1, 6), (2, 2, 5)] 112 | let square6 = [(0, 0, 9), (0, 2, 1), (1, 2, 8)] 113 | let square7 = [(0, 0, 7), (0, 1, 2), (0, 2, 4), (1, 0, 5), (1, 2, 9)] 114 | let square8 = [(0, 0, 6), (0, 1, 5), (1, 0, 4), (1, 2, 2)] 115 | let squares = [square0, square1, square2, square3, square4, square5, square6, square7, square8] 116 | return GridFactory.convertToCoordinateValues(squares: squares) 117 | } 118 | 119 | static var mediumGrid2: [CoordinateValue] { 120 | let square0 = [(0, 1, 6), (1, 2, 2), (2, 2, 7)] 121 | let square1 = [(0, 0, 3), (0, 2, 7), (1, 0, 6), (1, 2, 4), (2, 1, 8)] 122 | let square2 = [(1, 0, 1), (2, 0, 2)] 123 | let square3 = [(0, 0, 6), (0, 2, 8), (1, 0, 9), (2, 0, 5), (2, 1, 4)] 124 | let square4: [(Int, Int, Int)] = [] 125 | let square5 = [(0, 1, 9), (0, 2, 4), (1, 2, 1), (2, 0, 3), (2, 2, 2)] 126 | let square6 = [(0, 2, 6), (1, 2, 9)] 127 | let square7 = [(0, 1, 7), (1, 0, 2), (1, 2, 6), (2, 0, 9), (2, 2, 8)] 128 | let square8 = [(0, 0, 4), (1, 0, 8), (2, 1, 2)] 129 | let squares = [square0, square1, square2, square3, square4, square5, square6, square7, square8] 130 | return GridFactory.convertToCoordinateValues(squares: squares) 131 | } 132 | 133 | static var hardGrid: [CoordinateValue] { 134 | let square0 = [(0, 1, 1), (1, 0, 5), (1, 2, 3), (2, 0, 4)] 135 | let square1 = [(2, 0, 7), (2, 1, 1)] 136 | let square2 = [(0, 1, 3), (1, 2, 8), (2, 2, 9)] 137 | let square3 = [(0, 1, 9), (1, 2, 6)] 138 | let square4 = [(0, 0, 6), (1, 0, 5), (1, 2, 2), (2, 2, 9)] 139 | let square5 = [(1, 0, 9), (2, 1, 8)] 140 | let square6 = [(0, 0, 7), (1, 0, 1), (2, 1, 6)] 141 | let square7 = [(0, 1, 4), (0, 2, 5)] 142 | let square8 = [(0, 2, 1), (1, 0, 3), (1, 2, 2), (2, 1, 5)] 143 | let squares = [square0, square1, square2, square3, square4, square5, square6, square7, square8] 144 | return GridFactory.convertToCoordinateValues(squares: squares) 145 | } 146 | 147 | static var hardGrid1: [CoordinateValue] { 148 | let square0 = [(0, 2, 1), (1, 1, 9), (2, 1, 8)] 149 | let square1 = [(0, 1, 4), (0, 2, 6), (1, 2, 8), (2, 0, 5)] 150 | let square2 = [(0, 2, 2)] 151 | let square3 = [(1, 0, 1), (1, 1, 3), (1, 2, 8), (2, 1, 7), (2, 2, 6)] 152 | let square4 = [(0, 0, 2), (2, 0, 3)] 153 | let square5 = [(1, 1, 5)] 154 | let square6 = [(0, 1, 1), (1, 0, 3)] 155 | let square7 = [(0, 1, 9), (1, 2, 5)] 156 | let square8 = [(0, 2, 8), (1, 0, 4), (1, 1, 2), (2, 1, 6), (2, 2, 1)] 157 | let squares = [square0, square1, square2, square3, square4, square5, square6, square7, square8] 158 | return GridFactory.convertToCoordinateValues(squares: squares) 159 | } 160 | 161 | static var hardGrid2: [CoordinateValue] { 162 | let square0 = [(0, 0, 9), (1, 2, 8), (2, 1, 5), (2, 2, 2)] 163 | let square1 = [(0, 1, 7), (1, 0, 9), (1, 1, 2)] 164 | let square2 = [(0, 0, 5), (1, 1, 6)] 165 | let square3 = [(1, 1, 9), (2, 1, 1)] 166 | let square4 = [(0, 0, 6), (0, 1, 8), (0, 2, 9), (2, 0, 7), (2, 1, 5), (2, 2, 2)] 167 | let square5 = [(0, 1, 2), (1, 1, 7)] 168 | let square6 = [(1, 1, 2), (2, 2, 7)] 169 | let square7 = [(1, 1, 9), (1, 2, 4), (2, 1, 6)] 170 | let square8 = [(0, 0, 3), (0, 1, 5), (1, 0, 6), (2, 2, 1)] 171 | let squares = [square0, square1, square2, square3, square4, square5, square6, square7, square8] 172 | return GridFactory.convertToCoordinateValues(squares: squares) 173 | } 174 | 175 | // MARK: - Conversion methods 176 | 177 | static func convertToCoordinateValues(squares: [[ShortCoordinate]]) -> [CoordinateValue] { 178 | return squares.enumerated().map { squareIndex, square -> [CoordinateValue] in 179 | return square.map { coordinate -> CoordinateValue in 180 | return CoordinateValue(r: coordinate.0, c: coordinate.1, s: squareIndex, v: coordinate.2) 181 | } 182 | }.flatMap { $0 } 183 | } 184 | 185 | static func convertToShortCoordinates(grid: [CoordinateValue]) -> [[ShortCoordinate]] { 186 | var convertedGrid = [[ShortCoordinate]]() 187 | 188 | let sortedGrid = grid.sorted { a, b in 189 | a.s < b.s 190 | } 191 | 192 | for coordinateValue in sortedGrid { 193 | let shortCoordinate = (coordinateValue.r, coordinateValue.c, coordinateValue.v) 194 | 195 | if coordinateValue.s >= 0 && coordinateValue.s < convertedGrid.count { 196 | convertedGrid[coordinateValue.s].append(shortCoordinate) 197 | } else { 198 | convertedGrid.append([shortCoordinate]) 199 | } 200 | } 201 | 202 | return convertedGrid 203 | } 204 | 205 | /// Returns a string representation of a grid. The grid is represented as an array of arrays where each array 206 | /// represents a square. Each array contains 9 integers; the 0 integer represents an empty space. The first 207 | /// square is the top left and the last square in the array is the bottom right. 208 | static func stringGridFor(grid: [CoordinateValue]) -> String { 209 | var stringGrid = [[Int]]() 210 | 211 | for _ in 0..<9 { 212 | let square = [Int](repeating: 0, count: 9) 213 | stringGrid.append(square) 214 | } 215 | 216 | for coordinate in grid { 217 | // [r0c0, r0c1, r0c2, r1c0, r1c1, r1c2, r2c0, r2c1, r2c2] 218 | var rowCol = 0 219 | 220 | if coordinate.r == 1 { 221 | rowCol += 3 222 | } else if coordinate.r == 2 { 223 | rowCol += 6 224 | } 225 | if coordinate.c == 1 { 226 | rowCol += 1 227 | } else if coordinate.c == 2 { 228 | rowCol += 2 229 | } 230 | 231 | stringGrid[coordinate.s][rowCol] = coordinate.v 232 | } 233 | 234 | return String(describing: stringGrid) 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /Sudoku/Models/GridValues.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GridValues.swift 3 | // Sudoku 4 | // 5 | // Created by Raymond Kim on 6/7/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// Maintains state for working grid `grid`. `startingGrid` contains the initial grid. 12 | @Observable final class GridValues { 13 | 14 | private(set) var grid: [CoordinateValue] 15 | private(set) var colorGrid: Set 16 | private(set) var startingGrid: [CoordinateValue] 17 | 18 | var isSolved: Bool { 19 | return SudokuSolver.gridIsSolved(self) 20 | } 21 | 22 | /// Used when starting a brand new game 23 | init(startingGrid: [CoordinateValue]) { 24 | self.grid = startingGrid 25 | self.startingGrid = startingGrid 26 | self.colorGrid = Set() 27 | self.populateEmptyColorGrid(grid) 28 | } 29 | 30 | /// Used when loading from a saved game. 31 | init(grid: [CoordinateValue], startingGrid: [CoordinateValue], colorGrid: Set?) { 32 | self.grid = grid 33 | self.startingGrid = startingGrid 34 | 35 | if let colorGrid = colorGrid { 36 | self.colorGrid = colorGrid 37 | } else { 38 | self.colorGrid = Set() 39 | self.populateEmptyColorGrid(grid) 40 | } 41 | } 42 | 43 | func values(in squareIndex: Int, grid: [CoordinateValue]) -> [CoordinateValue] { 44 | grid.filter { coordinateValue -> Bool in 45 | coordinateValue.s == squareIndex 46 | } 47 | } 48 | 49 | func resetTo(newGrid: [CoordinateValue]) { 50 | grid = newGrid 51 | startingGrid = newGrid 52 | populateEmptyColorGrid(grid) 53 | } 54 | 55 | func resetFrom(savedGame: GameConfig) { 56 | grid = savedGame.workingGrid 57 | startingGrid = savedGame.startingGrid 58 | colorGrid = savedGame.colorGrid 59 | } 60 | 61 | func add(_ coordinateValue: CoordinateValue) { 62 | let coordinate = Coordinate(r: coordinateValue.r, c: coordinateValue.c, s: coordinateValue.s) 63 | removeValue(at: coordinate) 64 | grid.append(coordinateValue) 65 | updateColorGrid(coordinateValue) 66 | } 67 | 68 | func removeValue(at coordinate: Coordinate) { 69 | grid.removeAll { coordinateValue -> Bool in 70 | coordinateValue.r == coordinate.r && coordinateValue.c == coordinate.c && coordinateValue.s == coordinate.s 71 | } 72 | } 73 | 74 | func containsAValue(at coordinate: Coordinate, grid: [CoordinateValue]) -> Bool { 75 | let result = grid.contains { coordinateValue -> Bool in 76 | let gridCoordinate = Coordinate(r: coordinateValue.r, c: coordinateValue.c, s: coordinateValue.s) 77 | return gridCoordinate == coordinate 78 | } 79 | return result 80 | } 81 | 82 | func getValue(at coordinate: Coordinate, grid: [CoordinateValue]) -> Int? { 83 | let squareValues = values(in: coordinate.s, grid: grid) 84 | return squareValues.filter({ coordinateValue -> Bool in 85 | let gridCoordinate = Coordinate(r: coordinateValue.r, c: coordinateValue.c, s: coordinateValue.s) 86 | return gridCoordinate == coordinate 87 | }).first?.v 88 | } 89 | 90 | func getCoordinateValue(at coordinate: Coordinate, grid: [CoordinateValue]) -> CoordinateValue? { 91 | return grid.filter({ coordinateValue -> Bool in 92 | let gridCoordinate = Coordinate(r: coordinateValue.r, c: coordinateValue.c, s: coordinateValue.s) 93 | return gridCoordinate == coordinate 94 | }).first 95 | } 96 | 97 | func foregroundColorFor(_ coordinate: CoordinateValue) -> Color? { 98 | colorGrid.first(where: { $0.coordinate == coordinate })?.color 99 | } 100 | 101 | // MARK: - Private methods 102 | 103 | /// Compares working grid and starting grid and returns whether there's a value at the 104 | /// specified coordinate only in the working grid. 105 | private func onlyWorkingGridHasValue(at coordinate: Coordinate) -> Bool { 106 | let workingGridHasAValue = containsAValue(at: coordinate, grid: grid) 107 | let startingGridHasAValue = containsAValue(at: coordinate, grid: startingGrid) 108 | return workingGridHasAValue && !startingGridHasAValue 109 | } 110 | 111 | private func updateColorGrid(_ coordinateValue: CoordinateValue) { 112 | guard onlyWorkingGridHasValue(at: coordinateValue.coordinate) else { 113 | // starting grid 114 | let coordinateColor = CoordinateColor(coordinate: coordinateValue, color: .black) 115 | colorGrid.update(with: coordinateColor) 116 | return 117 | } 118 | 119 | if value(coordinateValue.v, wouldBeInvalidAt: coordinateValue.coordinate) { 120 | // user has just entered an invalid digit 121 | let coordinateColor = CoordinateColor(coordinate: coordinateValue, color: .red) 122 | colorGrid.update(with: coordinateColor) 123 | } else { 124 | let coordinateColor = CoordinateColor(coordinate: coordinateValue, color: Color("dynamicBlue")) 125 | colorGrid.update(with: coordinateColor) 126 | } 127 | } 128 | 129 | /// Checks whether there's already a coordinate with the input value in the 130 | /// current coordinate's 3x3 square, starting grid row, or starting grid column. 131 | private func value(_ value: Int, wouldBeInvalidAt coordinate: Coordinate) -> Bool { 132 | return square(coordinate.s, contains: value) || 133 | fullRow(for: coordinate, contains: value) || 134 | fullColumn(for: coordinate, contains: value) 135 | } 136 | 137 | private func square(_ squareIndex: Int, contains value: Int) -> Bool { 138 | let squareValues = values(in: squareIndex, grid: startingGrid) 139 | return squareValues.contains { coordinateValue -> Bool in 140 | return coordinateValue.v == value 141 | } 142 | } 143 | 144 | private func fullRow(for coordinate: Coordinate, contains value: Int) -> Bool { 145 | var rowCoordinates = [CoordinateValue]() 146 | 147 | if (0...2).contains(coordinate.s) { 148 | rowCoordinates = startingGrid.filter { coordinateValue -> Bool in 149 | return coordinateValue.r == coordinate.r && (0...2).contains(coordinateValue.s) 150 | } 151 | } else if (3...5).contains(coordinate.s) { 152 | rowCoordinates = startingGrid.filter { coordinateValue -> Bool in 153 | return coordinateValue.r == coordinate.r && (3...5).contains(coordinateValue.s) 154 | } 155 | } else if (6...8).contains(coordinate.s) { 156 | rowCoordinates = startingGrid.filter { coordinateValue -> Bool in 157 | return coordinateValue.r == coordinate.r && (6...8).contains(coordinateValue.s) 158 | } 159 | } 160 | 161 | var containsValueInRow = false 162 | for coordinate in rowCoordinates { 163 | if coordinate.v == value { 164 | containsValueInRow = true 165 | break 166 | } 167 | } 168 | return containsValueInRow 169 | } 170 | 171 | private func fullColumn(for coordinate: Coordinate, contains value: Int) -> Bool { 172 | var colCoordinates = [CoordinateValue]() 173 | 174 | if [0, 3, 6].contains(coordinate.s) { 175 | colCoordinates = startingGrid.filter { coordinateValue -> Bool in 176 | return coordinateValue.c == coordinate.c && [0, 3, 6].contains(coordinateValue.s) 177 | } 178 | } else if [1, 4, 7].contains(coordinate.s) { 179 | colCoordinates = startingGrid.filter { coordinateValue -> Bool in 180 | return coordinateValue.c == coordinate.c && [1, 4, 7].contains(coordinateValue.s) 181 | } 182 | } else if [2, 5, 8].contains(coordinate.s) { 183 | colCoordinates = startingGrid.filter { coordinateValue -> Bool in 184 | return coordinateValue.c == coordinate.c && [2, 5, 8].contains(coordinateValue.s) 185 | } 186 | } 187 | 188 | var containsValueInCol = false 189 | for coordinate in colCoordinates { 190 | if coordinate.v == value { 191 | containsValueInCol = true 192 | break 193 | } 194 | } 195 | return containsValueInCol 196 | } 197 | 198 | private func populateEmptyColorGrid(_ grid: [CoordinateValue]) { 199 | grid.forEach { coordinateValue in 200 | let coordinateColor = CoordinateColor(coordinate: coordinateValue, color: .black) 201 | colorGrid.update(with: coordinateColor) 202 | } 203 | } 204 | } 205 | 206 | extension GridValues: Hashable { 207 | static func == (lhs: GridValues, rhs: GridValues) -> Bool { 208 | return lhs.grid == rhs.grid && 209 | lhs.colorGrid == rhs.colorGrid && 210 | lhs.startingGrid == rhs.startingGrid 211 | } 212 | 213 | func hash(into hasher: inout Hasher) { 214 | hasher.combine(grid) 215 | hasher.combine(colorGrid) 216 | hasher.combine(startingGrid) 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /Sudoku/Models/Hint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Hint.swift 3 | // Sudoku 4 | // 5 | // Created by Ray Kim on 2/9/25. 6 | // Copyright © 2025 Self. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Hint { 12 | enum HintType { 13 | case nakedSingle 14 | case hiddenSingle 15 | case open 16 | 17 | var rawValue: String { 18 | switch self { 19 | case .nakedSingle: 20 | return "There is at least one naked single on this board. Can you find it?" 21 | case .hiddenSingle: 22 | return "There is at least one hidden single on this board. Can you find it?" 23 | case .open: 24 | return "fallback" 25 | } 26 | } 27 | } 28 | let coordinate: CoordinateValue? 29 | let hintType: HintType 30 | private let overrideDescription: String? 31 | 32 | init(coordinate: CoordinateValue? = nil, hintType: HintType, overrideDescription: String? = nil) { 33 | self.coordinate = coordinate 34 | self.hintType = hintType 35 | self.overrideDescription = overrideDescription 36 | } 37 | 38 | var description: String { 39 | return overrideDescription ?? hintType.rawValue 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sudoku/Models/SelectedCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SelectedCell.swift 3 | // Sudoku 4 | // 5 | // Created by Raymond Kim on 6/3/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | @Observable final class SelectedCell { 12 | var coordinate: Coordinate? 13 | 14 | init(coordinate: Coordinate? = nil) { 15 | self.coordinate = coordinate 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sudoku/Models/UndoManager.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | enum EditActionType { 4 | case none 5 | case add(digit: Int) 6 | case remove(digit: Int) 7 | } 8 | 9 | struct UndoAction { 10 | let coordinate: Coordinate 11 | let previousValue: Int? 12 | let previousEditValues: Set 13 | let wasEditing: Bool 14 | let editActionType: EditActionType 15 | } 16 | 17 | @Observable final class UndoManager { 18 | private(set) var actions: [UndoAction] = [] 19 | 20 | var canUndo: Bool { 21 | !actions.isEmpty 22 | } 23 | 24 | func addAction(_ action: UndoAction) { 25 | actions.append(action) 26 | } 27 | 28 | func undo() -> UndoAction? { 29 | guard canUndo else { return nil } 30 | return actions.removeLast() 31 | } 32 | } -------------------------------------------------------------------------------- /Sudoku/Models/UserAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserAction.swift 3 | // Sudoku 4 | // 5 | // Created by Raymond Kim on 6/4/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | @Observable final class UserAction { 12 | enum ActionType: Equatable, Codable { 13 | case none 14 | case clear 15 | case digit(_ digit: Int) 16 | } 17 | 18 | var action: ActionType = .none 19 | 20 | init(action: ActionType = .none) { 21 | self.action = action 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sudoku/Models/WindowSize.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WindowSize.swift 3 | // Sudoku 4 | // 5 | // Created by Ray Kim on 7/14/24. 6 | // Copyright © 2024 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | @Observable final class WindowSize { 12 | var size: CGSize 13 | 14 | init(size: CGSize) { 15 | self.size = size 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sudoku/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sudoku/SettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsView.swift 3 | // Sudoku 4 | // 5 | // Created by Ray Kim on 6/30/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import SwiftData 11 | 12 | struct SettingsView: View { 13 | @Environment(\.modelContext) private var modelContext 14 | 15 | #if os(visionOS) 16 | @State private var animate = false 17 | private let text = "Special shoutout to Don for helping me test this app for the Apple Vision Pro 🎉" 18 | #endif 19 | 20 | private let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" 21 | private let buildVersion = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "" 22 | 23 | @State private var showingDeleteConfirmation = false 24 | @State private var showingCacheClearConfirmation = false 25 | 26 | var body: some View { 27 | VStack(spacing: 24) { 28 | Text("Sudoku AI") 29 | .font(Font.system(.largeTitle, design: .rounded).bold()) 30 | Text("Version \(appVersion) (\(buildVersion))") 31 | .font(Font.system(.headline, design: .rounded)) 32 | Text("© 2023 Ray Kim") 33 | .font(Font.system(.headline, design: .rounded)) 34 | Button(action: { 35 | let url = URL(string: "https://www.facebook.com/Sudoku-Classic-105010301266062")! 36 | UIApplication.shared.open(url) 37 | }) { 38 | Text("Website") 39 | .font(Font.system(.headline, design: .rounded)) 40 | } 41 | Button(action: { 42 | // clear user defaults (e.g., achievements) 43 | if let bundleID = Bundle.main.bundleIdentifier { 44 | UserDefaults.standard.removePersistentDomain(forName: bundleID) 45 | } 46 | 47 | // clear persisted data (e.g., saved game) 48 | do { 49 | try modelContext.delete(model: GameConfig.self) 50 | try modelContext.save() 51 | } catch { 52 | print("Error deleting SwiftData models: \(error)") 53 | } 54 | 55 | showingDeleteConfirmation = true 56 | }) { 57 | Text("Delete saved data") 58 | .font(Font.system(.headline, design: .rounded)) 59 | .foregroundColor(.red) 60 | } 61 | .alert("Data Deleted", isPresented: $showingDeleteConfirmation) { 62 | Button("OK", role: .cancel) { } 63 | } message: { 64 | Text("Data has been deleted successfully.") 65 | } 66 | Button(action: { 67 | HintButton.clearCache() 68 | showingCacheClearConfirmation = true 69 | }) { 70 | Text("Clear hint cache") 71 | .font(Font.system(.headline, design: .rounded)) 72 | .foregroundColor(.red) 73 | } 74 | .alert("Cache cleared", isPresented: $showingCacheClearConfirmation) { 75 | Button("OK", role: .cancel) { } 76 | } message: { 77 | Text("Cache has been cleared successfully.") 78 | } 79 | #if os(visionOS) 80 | HStack(spacing: 0) { 81 | ForEach(0.. Path 14 | 15 | init(_ wrapped: S) { 16 | self.path = { rect in 17 | let path = wrapped.path(in: rect) 18 | return path 19 | } 20 | } 21 | 22 | func path(in rect: CGRect) -> Path { 23 | path(rect) 24 | } 25 | } 26 | 27 | extension AnyShape: @unchecked Sendable {} 28 | -------------------------------------------------------------------------------- /Sudoku/Shared/Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Extensions.swift 3 | // Sudoku 4 | // 5 | // Created by Raymond Kim on 6/2/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | extension View { 12 | func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { 13 | ModifiedContent(content: self, modifier: CornerRadiusStyle(radius: radius, corners: corners)) 14 | } 15 | 16 | var isIphone: Bool { 17 | UIDevice.current.userInterfaceIdiom == .phone 18 | } 19 | 20 | var isIpad: Bool { 21 | UIDevice.current.userInterfaceIdiom == .pad 22 | } 23 | 24 | var isVision: Bool { 25 | UIDevice.current.userInterfaceIdiom == .vision 26 | } 27 | 28 | func shimmer() -> some View { 29 | modifier(ShimmerEffect()) 30 | } 31 | } 32 | 33 | extension ViewModifier { 34 | var isIpad: Bool { 35 | UIDevice.current.userInterfaceIdiom == .pad 36 | } 37 | 38 | var isVision: Bool { 39 | UIDevice.current.userInterfaceIdiom == .vision 40 | } 41 | } 42 | 43 | extension Color: Codable { 44 | 45 | init(hex: String) { 46 | let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) 47 | var int: UInt64 = 0 48 | Scanner(string: hex).scanHexInt64(&int) 49 | let a, r, g, b: UInt64 50 | switch hex.count { 51 | case 3: // RGB (12-bit) 52 | (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) 53 | case 6: // RGB (24-bit) 54 | (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) 55 | case 8: // ARGB (32-bit) 56 | (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) 57 | default: 58 | (a, r, g, b) = (255, 0, 0, 0) 59 | } 60 | self.init( 61 | .sRGB, 62 | red: Double(r) / 255, 63 | green: Double(g) / 255, 64 | blue: Double(b) / 255, 65 | opacity: Double(a) / 255 66 | ) 67 | } 68 | 69 | // Convert Color to hex string 70 | func toHex() -> String? { 71 | let components = UIColor(self).cgColor.components 72 | guard let red = components?[0], let green = components?[1], let blue = components?[2] else { 73 | return nil 74 | } 75 | let alpha = components?.count == 4 ? components?[3] : 1.0 76 | if alpha == 1.0 { 77 | return String(format: "#%02lX%02lX%02lX", lround(Double(red * 255)), lround(Double(green * 255)), lround(Double(blue * 255))) 78 | } else { 79 | return String(format: "#%02lX%02lX%02lX%02lX", lround(Double(alpha! * 255)), lround(Double(red * 255)), lround(Double(green * 255)), lround(Double(blue * 255))) 80 | } 81 | } 82 | 83 | public init(from decoder: Decoder) throws { 84 | let container = try decoder.singleValueContainer() 85 | let hexString = try container.decode(String.self) 86 | self.init(hex: hexString) 87 | } 88 | 89 | public func encode(to encoder: any Encoder) throws { 90 | var container = encoder.singleValueContainer() 91 | guard let hexString = self.toHex() else { 92 | throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "invalid color")) 93 | } 94 | try container.encode(hexString) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Sudoku/Shared/View Modifiers/CornerRadiusStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CornerRadiusStyle.swift 3 | // Sudoku 4 | // 5 | // Created by Raymond Kim on 6/2/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct CornerRadiusStyle: ViewModifier { 12 | var radius: CGFloat 13 | var corners: UIRectCorner 14 | 15 | struct CornerRadiusShape: Shape { 16 | var radius = CGFloat.infinity 17 | var corners = UIRectCorner.allCorners 18 | 19 | func path(in rect: CGRect) -> Path { 20 | let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius)) 21 | return Path(path.cgPath) 22 | } 23 | } 24 | 25 | func body(content: Content) -> some View { 26 | content 27 | .clipShape(CornerRadiusShape(radius: radius, corners: corners)) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sudoku/Shared/View Modifiers/DynamicButtonStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DynamicButtonStyle.swift 3 | // Sudoku 4 | // 5 | // Created by Ray Kim on 7/2/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct DynamicButtonStyle: ViewModifier { 12 | 13 | let textColor: Color? 14 | let backgroundColor: Color? 15 | let isImage: Bool 16 | 17 | var buttonVerticalPadding: CGFloat { 18 | if isIpad { 19 | return isImage ? 20.5 : 24 20 | } else { 21 | return 10 22 | } 23 | } 24 | 25 | var buttonHorizontalPadding: CGFloat { 26 | isIpad ? 38 : 16 27 | } 28 | 29 | func body(content: Content) -> some View { 30 | if isVision { 31 | content 32 | .foregroundColor(textColor) 33 | .background(backgroundColor) 34 | .clipShape(Capsule()) 35 | } else { 36 | content 37 | .foregroundColor(textColor) 38 | .padding(.vertical, buttonVerticalPadding) 39 | .padding(.horizontal, buttonHorizontalPadding) 40 | .background(backgroundColor) 41 | .cornerRadius(8) 42 | } 43 | } 44 | } 45 | 46 | extension View { 47 | func dynamicButtonStyle(textColor: Color? = nil, backgroundColor: Color? = nil) -> some View { 48 | self.modifier(DynamicButtonStyle(textColor: textColor, backgroundColor: backgroundColor, isImage: false)) 49 | } 50 | 51 | func dynamicButtonImageStyle(textColor: Color? = nil, backgroundColor: Color? = nil) -> some View { 52 | self.modifier(DynamicButtonStyle(textColor: textColor, backgroundColor: backgroundColor, isImage: true)) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sudoku/Shared/View Modifiers/FullBackgroundStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FullBackgroundStyle.swift 3 | // Sudoku 4 | // 5 | // Created by Ray Kim on 7/2/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// Used for customizing the background color extending over safe area insets. 12 | struct FullBackgroundStyle: ViewModifier { 13 | 14 | let color: Color 15 | 16 | func body(content: Content) -> some View { 17 | ZStack { 18 | color 19 | .edgesIgnoringSafeArea(.all) 20 | content 21 | } 22 | } 23 | } 24 | 25 | extension View { 26 | /// Used for customizing the background color extending over safe area insets. 27 | func fullBackgroundStyle(_ color: Color = Color("dynamicBackground")) -> some View { 28 | self.modifier(FullBackgroundStyle(color: color)) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sudoku/Shared/View Modifiers/ShimmerEffect.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShimmerEffect.swift 3 | // Sudoku 4 | // 5 | // Created by Ray Kim on 12/31/24. 6 | // Copyright © 2024 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ShimmerEffect: ViewModifier { 12 | @State private var phase: CGFloat = 0 13 | 14 | func body(content: Content) -> some View { 15 | content 16 | .mask { 17 | Rectangle() 18 | .fill( 19 | LinearGradient( 20 | gradient: Gradient(stops: [ 21 | .init(color: .clear, location: 0), 22 | .init(color: .black.opacity(0.5), location: 0.45), 23 | .init(color: .black.opacity(0.5), location: 0.55), 24 | .init(color: .clear, location: 1) 25 | ]), 26 | startPoint: .topLeading, 27 | endPoint: .bottomTrailing 28 | ) 29 | ) 30 | .offset(x: -100) 31 | .offset(x: phase) 32 | } 33 | .onAppear { 34 | withAnimation(.linear(duration: 3).delay(0.25).repeatForever(autoreverses: false)) { 35 | phase = 400 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sudoku/Shared/ViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewModel.swift 3 | // Sudoku 4 | // 5 | // Created by Ray Kim on 6/25/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | protocol ViewModel { 12 | var isIpad: Bool { get } 13 | var isVision: Bool { get } 14 | } 15 | 16 | extension ViewModel { 17 | var isIpad: Bool { 18 | UIDevice.current.userInterfaceIdiom == .pad 19 | } 20 | 21 | var isVision: Bool { 22 | UIDevice.current.userInterfaceIdiom == .vision 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sudoku/Stats/StatsRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StatsRow.swift 3 | // Sudoku 4 | // 5 | // Created by Assistant on 3/14/2024. 6 | // Copyright © 2024 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct StatsRow: View { 12 | let title: String 13 | let value: String 14 | 15 | var body: some View { 16 | HStack { 17 | Text(title) 18 | Spacer() 19 | Text(value) 20 | .tint(Color("dynamicDarkGray")) 21 | } 22 | } 23 | } 24 | 25 | #Preview { 26 | StatsRow(title: "Sample Stat", value: "42") 27 | } 28 | -------------------------------------------------------------------------------- /Sudoku/Stats/StatsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StatsView.swift 3 | // Sudoku 4 | // 5 | // Created by Ray Kim on 10/19/24. 6 | // Copyright © 2024 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct StatsView: View { 12 | 13 | @AppStorage("totalGamesCompleted") var totalGamesCompleted = 0 14 | @AppStorage("totalEasyGamesCompleted") var totalEasyGamesCompleted = 0 15 | @AppStorage("totalMediumGamesCompleted") var totalMediumGamesCompleted = 0 16 | @AppStorage("totalHardGamesCompleted") var totalHardGamesCompleted = 0 17 | @AppStorage("fastestTimeCompleted") var fastestTimeCompleted: TimeInterval? 18 | 19 | private var formattedFastestTime: String? { 20 | guard let fastestTime = fastestTimeCompleted else { return nil } 21 | 22 | let hours = Int(fastestTime) / 3600 23 | let minutes = Int(fastestTime) / 60 % 60 24 | let seconds = Int(fastestTime) % 60 25 | 26 | if hours > 0 { 27 | return "\(hours) hours, \(minutes) minutes, and \(seconds) seconds" 28 | } else if minutes > 0 { 29 | return "\(minutes) minutes and \(seconds) seconds" 30 | } else { 31 | return "\(seconds) seconds" 32 | } 33 | } 34 | 35 | var body: some View { 36 | List { 37 | if let formattedFastestTime = formattedFastestTime { 38 | StatsRow(title: "Fastest time completed", value: formattedFastestTime) 39 | } 40 | StatsRow(title: "Total games completed", value: "\(totalGamesCompleted)") 41 | Section("Difficulty") { 42 | StatsRow(title: "Easy", value: "\(totalEasyGamesCompleted)") 43 | StatsRow(title: "Medium", value: "\(totalMediumGamesCompleted)") 44 | StatsRow(title: "Hard", value: "\(totalHardGamesCompleted)") 45 | } 46 | } 47 | .font(Font.system(.headline, design: .rounded)) 48 | .fullBackgroundStyle() 49 | .navigationTitle("Stats") 50 | .navigationBarTitleDisplayMode(.large) 51 | } 52 | } 53 | 54 | #Preview { 55 | StatsView() 56 | } 57 | -------------------------------------------------------------------------------- /Sudoku/SudokuApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SudokuApp.swift 3 | // Sudoku 4 | // 5 | // Created by Ray Kim on 1/10/24. 6 | // Copyright © 2024 Self. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import SwiftData 11 | 12 | @main 13 | struct SudokuApp: App { 14 | 15 | var body: some Scene { 16 | WindowGroup { 17 | GeometryReader { geometry in 18 | MenuView() 19 | .environment(WindowSize(size: geometry.size)) 20 | } 21 | } 22 | .modelContainer(for: GameConfig.self) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sudoku/SudokuSolver.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SudokuSolver.swift 3 | // Sudoku 4 | // 5 | // Created by Raymond Kim on 6/12/20. 6 | // Copyright © 2020 Self. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct SudokuSolver { 12 | static func gridIsSolved(_ gridValues: GridValues) -> Bool { 13 | let grid = gridValues.grid 14 | 15 | let indexRange = (0...8) 16 | 17 | // check each 3x3 square 18 | for squareIndex in indexRange { 19 | let squareValues = gridValues.values(in: squareIndex, grid: gridValues.grid) 20 | if !SudokuSolver.validate(squareValues) { 21 | return false 22 | } 23 | } 24 | 25 | // check each full row 26 | let gridConverted = convertGrid(grid) 27 | for rowIndex in indexRange { 28 | let rowValues = gridConverted.filter { $0.r == rowIndex } 29 | if !SudokuSolver.validate(rowValues) { 30 | return false 31 | } 32 | } 33 | 34 | // check each full column 35 | for colIndex in indexRange { 36 | let colValues = gridConverted.filter { $0.c == colIndex } 37 | if !SudokuSolver.validate(colValues) { 38 | return false 39 | } 40 | } 41 | 42 | return true 43 | } 44 | 45 | /// Checks whether the array of values contains any duplicates–returns false if there are. 46 | static func validate(_ coordinateValues: [CoordinateValue]) -> Bool { 47 | var set = Set() 48 | 49 | let values = coordinateValues.map { $0.v } 50 | for value in values { 51 | if set.insert(value).inserted == false { 52 | return false 53 | } 54 | } 55 | return true 56 | } 57 | 58 | /// Transforms relative coordinate value grid to absolute coordinate value grid. 59 | static func convertGrid(_ grid: [CoordinateValue]) -> [CoordinateValue] { 60 | return grid.map { 61 | let absoluteRow = convertToFullRowIndex(rowIndex: $0.r, squareIndex: $0.s) 62 | let absoluteCol = convertToFullColIndex(colIndex: $0.c, squareIndex: $0.s) 63 | return CoordinateValue(r: absoluteRow, c: absoluteCol, s: $0.s, v: $0.v) 64 | } 65 | } 66 | 67 | /// Note: Square indices ascend from left to right, top to bottom of grid. For example, 68 | /// top-left square in grid has square index 0, top-center square has index 1, top-right 69 | /// square has index 2, mid-left square has index 3, etc. 70 | static func convertToFullRowIndex(rowIndex: Int, squareIndex: Int) -> Int { 71 | if squareIndex > 2 && squareIndex < 6 { 72 | return rowIndex + 3 73 | } else if squareIndex >= 6 { 74 | return rowIndex + 6 75 | } else { 76 | return rowIndex 77 | } 78 | } 79 | 80 | /// Note: Square indices ascend from left to right, top to bottom of grid. For example, 81 | /// top-left square in grid has square index 0, top-center square has index 1, top-right 82 | /// square has index 2, mid-left square has index 3, etc. 83 | static func convertToFullColIndex(colIndex: Int, squareIndex: Int) -> Int { 84 | if [1, 4, 7].contains(squareIndex) { 85 | return colIndex + 3 86 | } else if [2, 5, 8].contains(squareIndex) { 87 | return colIndex + 6 88 | } else { 89 | return colIndex 90 | } 91 | } 92 | 93 | /// Creates a full 9x9 grid with empty cells marked as nil 94 | static func createFullGrid(_ partialGrid: [CoordinateValue]) -> [[CoordinateValue?]] { 95 | var fullGrid = Array(repeating: Array(repeating: nil as CoordinateValue?, count: 9), count: 9) 96 | let convertedGrid = convertGrid(partialGrid) 97 | 98 | // Fill in the known values 99 | for value in convertedGrid { 100 | fullGrid[value.r][value.c] = value 101 | } 102 | 103 | return fullGrid 104 | } 105 | 106 | /// Finds naked and hidden singles in the grid 107 | /// - Parameter gridValues: The current state of the Sudoku grid 108 | /// - Returns: Array of tuples containing coordinate and description of found singles 109 | static func findSingles(_ gridValues: [CoordinateValue]) -> [Hint] { 110 | var singles = [Hint]() 111 | let fullGrid = createFullGrid(gridValues) 112 | let convertedGrid = convertGrid(gridValues) 113 | 114 | // Check each cell in the grid 115 | for row in 0..<9 { 116 | for col in 0..<9 { 117 | // Skip filled cells 118 | if fullGrid[row][col] != nil { continue } 119 | 120 | // Calculate square index 121 | let squareIndex = (row / 3) * 3 + (col / 3) 122 | 123 | // Get all values in the same row, column, and square 124 | let rowValues = convertedGrid.filter { $0.r == row }.map { $0.v } 125 | let colValues = convertedGrid.filter { $0.c == col }.map { $0.v } 126 | let squareValues = convertedGrid.filter { $0.s == squareIndex }.map { $0.v } 127 | 128 | // Find possible values for this cell 129 | let allPossible = Set(1...9) 130 | let usedValues = Set(rowValues + colValues + squareValues) 131 | let possibleValues = allPossible.subtracting(usedValues) 132 | 133 | // Check for naked single (only one possible value) 134 | if possibleValues.count == 1 { 135 | if let value = possibleValues.first { 136 | let nakedSingle = CoordinateValue(r: row, c: col, s: squareIndex, v: value) 137 | let hint = Hint(coordinate: nakedSingle, hintType: .nakedSingle) 138 | singles.append(hint) 139 | } 140 | } 141 | 142 | // Check for hidden single in row, column, and square 143 | for value in 1...9 { 144 | if !usedValues.contains(value) { 145 | let emptyCellsInRow = (0..<9).filter { fullGrid[row][$0] == nil }.count 146 | let emptyCellsInCol = (0..<9).filter { fullGrid[$0][col] == nil }.count 147 | let squareStartRow = (row / 3) * 3 148 | let squareStartCol = (col / 3) * 3 149 | let emptyCellsInSquare = (squareStartRow..() 55 | 56 | XCTAssertTrue(guesses == expectedGuesses) 57 | } 58 | 59 | func testGuessesForColumnIndexOneGuess() { 60 | let coordinateEditValues = CoordinateEditValues(r: 0, c: 0, s: 0, values: [0]) 61 | let viewModel = RowViewModel(index: 0, squareIndex: 0, guesses: [coordinateEditValues]) 62 | let guesses = viewModel.guessesFor(0) 63 | let expectedGuesses: Set = [0] 64 | 65 | XCTAssertTrue(guesses == expectedGuesses) 66 | } 67 | } 68 | --------------------------------------------------------------------------------