├── .gitignore ├── Images ├── Recording.gif └── appstore.png ├── LICENSE ├── Readme.md ├── Scripts ├── sortProject └── sortProject.pl ├── SimpleMath.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ └── SimpleMath.xcscheme ├── SimpleMath ├── App │ ├── App.swift │ ├── Equations │ │ ├── Equation.swift │ │ ├── EquationSettings.swift │ │ ├── EquationType.swift │ │ ├── EquationsFactory.swift │ │ ├── EquationsFactoryImp.swift │ │ ├── EquationsFactoryMock.swift │ │ ├── GeneratedResult.swift │ │ ├── Int+IsPrime.swift │ │ ├── Operator.swift │ │ └── SettingsBundle+EquationSettings.swift │ ├── Helpers │ │ ├── AnyPublisher+Just.swift │ │ └── Assign+WeakCapture.swift │ ├── Onboarding.swift │ ├── OnboardingBundle.swift │ ├── SettingsViewModel.swift │ └── SimpleMathViewModel.swift ├── AppDelegate.swift ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── Icon-60@2x.png │ │ ├── Icon-60@3x.png │ │ ├── Icon-76.png │ │ ├── Icon-76@2x.png │ │ ├── Icon-83.5@2x.png │ │ ├── Icon-Small-40.png │ │ ├── Icon-Small-40@2x-1.png │ │ ├── Icon-Small-40@2x.png │ │ ├── Icon-Small-40@3x.png │ │ ├── Icon-Small-41.png │ │ ├── Icon-Small-42.png │ │ ├── Icon-Small.png │ │ ├── Icon-Small@2x-1.png │ │ ├── Icon-Small@2x.png │ │ ├── Icon-Small@3x.png │ │ ├── iTunesArtwork@2x.png │ │ ├── icon-20.png │ │ └── icon-60.png │ └── Contents.json ├── Audio Engine │ ├── AudioEngine.swift │ ├── AudioEngineMock.swift │ ├── AudioPlayer.swift │ ├── Sound Resources │ │ ├── failure.m4a │ │ ├── great-success.m4a │ │ └── success.m4a │ └── Sound.swift ├── Base.lproj │ └── LaunchScreen.storyboard ├── Info.plist ├── SceneDelegate.swift ├── Settings │ ├── Settings.swift │ ├── SettingsBundle.swift │ ├── SettingsMock.swift │ └── StoredSettings.swift ├── Storage │ ├── Storage.swift │ ├── StorageMock.swift │ └── UserDefaultsStorage.swift └── UI │ ├── ContentView.swift │ ├── Helpers │ ├── BoundsAnchorKey.swift │ ├── Color+NamedColors.swift │ ├── HostingViewController.swift │ ├── Image+Symbol.swift │ ├── ModalPresentation.swift │ ├── Optional+UserInterfaceSizeClass.swift │ ├── ScreenExtensions.swift │ ├── SizeKey.swift │ ├── Symbol.swift │ └── View+Debug.swift │ ├── Main │ ├── CommandInputButton.swift │ ├── CorrectAnswersView.swift │ ├── EquationsView.swift │ ├── InputView.swift │ ├── NumbersInputView.swift │ ├── PlayPauseView.swift │ └── ProgressView.swift │ ├── Results │ ├── GreatSuccessOverlayView.swift │ ├── ResetButton.swift │ ├── ResultRowView.swift │ ├── ResultsHeaderView.swift │ └── ResultsView.swift │ └── Settings │ ├── DigitRangeSettingSectionView.swift │ ├── EquationTypeSettingView.swift │ ├── MiniDeviceView.swift │ ├── NumberOfEquationsSettingView.swift │ ├── SettingCell.swift │ ├── SettingsPanelView.swift │ └── SoundToggleSettingView.swift └── SimpleMathTests ├── .swiftlint.yml ├── App ├── Equations │ ├── EquationTests.swift │ ├── EquationsFactoryImpTests.swift │ └── SettingsBundle+EquationSettingsTests.swift ├── OnboardingTests.swift ├── SettingsViewModelTests.swift └── SimpleMathViewModelTests.swift ├── Helpers └── Builder.swift ├── Info.plist └── Settings ├── StoredSettingsTests.swift └── UserDefaultsStorageTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | -------------------------------------------------------------------------------- /Images/Recording.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filiplazov/SimpleMath/1d56a06d3ebf5ca7d01c4643581b4bf24078dfec/Images/Recording.gif -------------------------------------------------------------------------------- /Images/appstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filiplazov/SimpleMath/1d56a06d3ebf5ca7d01c4643581b4bf24078dfec/Images/appstore.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Filip Lazov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Simple Math app 2 | 3 | [![Build Status](https://app.bitrise.io/app/bdde9ef31505ea1c/status.svg?token=Hm5PXHsL4uQeV_oGsKFtuA&branch=master)](https://app.bitrise.io/app/bdde9ef31505ea1c) 4 | 5 | SimpleMath is an app that generates simple math equations for young children to help them solve and learn in a fun way. I've made this app for my 6 year old daughter because I don't have to write or print the equations and I don't need to evaluate them as well, the app does it all. 6 | I am very happy I can make my daughter's learning process easier, but this project has also given me a great "toy" to play around and learn / practice SwiftUI & Combine. 7 | It is heavily inspired by Paul Hudson's recent [SwiftUI Live video](https://www.youtube.com/watch?v=FE4ys3tW1VI), I highly recommend it. 8 | 9 | ## Features 10 | 11 | - [x] Generate addition, subtraction, multiplication and division equations. 12 | - [x] Record results and provide visual and audio feedback. 13 | - [x] Show progress of completed equations as well as correct answers. 14 | - [x] Display results after a completed session with corrections on wrong answers. 15 | - [x] An option to start a new session after finishing. 16 | - [x] Display a simple cheerful animation if all equations are solved correctly. 17 | - [x] Settings UI that allows customization of: 18 | - [x] Operand digit input range. 19 | - [x] Number of generated equations (minimum 5, maximum 30). 20 | - [x] Enable / disable equation types: addition, subtraction, multiplication, division. 21 | - [x] Toggle sounds. 22 | - [x] Scaling fonts and UI for all supported iOS13+ devices. 23 | 24 |

25 | SimpleMath 26 |

27 | 28 | ## Todo 29 | 30 | - [ ] Add light / dark mode support. 31 | - [ ] Customize colors / themes (it is very purple now, my target audience demanded it!). 32 | - [ ] Support landscape layout. 33 | - [ ] Adaptive sessions, use wrong answers from previous sessions, repetition is key! 34 | - [ ] More gamification, with sounds and visual effects, simple achievement system. 35 | - [ ] Helpful hints when tapping on current equation. 36 | - [ ] Flexible equation layout, ex `1 + _ = 3`. 37 | - [ ] Whatever my target audience demands! 38 | 39 | ## Is this app available on the App Store? 40 | 41 | Yes, click on the link below. 42 | 43 |

44 | 45 | App Store 46 | 47 |

48 | 49 | ## What about Android? 50 | 51 | At this time I have no plans to support Android, but you are more than welcome to implement an Android version yourself. 52 | 53 | ## Requirements 54 | 55 | - iOS 13.2+ 56 | - Xcode 11.4+ 57 | - Swift 5.2+ 58 | 59 | ## Author 60 | * [Filip Lazov](https://github.com/filiplazov) ([@filiplazov](https://twitter.com/filiplazov)) 61 | 62 | ## Credits 63 | SimpleMath was inspired by the following projects: 64 | 65 | * [SwiftUI Live: Building an app from scratch](https://www.youtube.com/watch?v=FE4ys3tW1VI) by [Paul Hudson](https://twitter.com/twostraws) 66 | * [Build a SwiftUI App for iOS13](https://designcode.io/swiftui?promo=learnswiftui) by [Meng To](https://twitter.com/MengTo) (Design+code) 67 | * [Thinking in SwiftUI](https://www.objc.io/books/thinking-in-swiftui/) A book by [Chris Eidhof](https://twitter.com/chriseidhof) and [Florian Kugler](https://twitter.com/floriankugler) 68 | 69 | ## License 70 | 71 | SimpleMath is available under the MIT license. See the [LICENSE](LICENSE) file for more info. 72 | 73 | All sounds in this project are made using Garage Band and are royalty free. -------------------------------------------------------------------------------- /Scripts/sortProject: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Usage ./sortProject 4 | 5 | SCRIPT_DIR="$( cd "$( dirname "$0" )" && pwd )" 6 | perl "$SCRIPT_DIR"/sortProject.pl "$SCRIPT_DIR"/../SimpleMath.xcodeproj 7 | -------------------------------------------------------------------------------- /Scripts/sortProject.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env perl 2 | 3 | # Copyright (C) 2007, 2008, 2009, 2010 Apple Inc. All rights reserved. 4 | # 5 | # Redistribution and use in source and binary forms, with or without 6 | # modification, are permitted provided that the following conditions 7 | # are met: 8 | # 9 | # 1. Redistributions of source code must retain the above copyright 10 | # notice, this list of conditions and the following disclaimer. 11 | # 2. Redistributions in binary form must reproduce the above copyright 12 | # notice, this list of conditions and the following disclaimer in the 13 | # documentation and/or other materials provided with the distribution. 14 | # 3. Neither the name of Apple Inc. ("Apple") nor the names of 15 | # its contributors may be used to endorse or promote products derived 16 | # from this software without specific prior written permission. 17 | # 18 | # THIS SOFTWARE IS PROVIDED BY APPLE AND ITS CONTRIBUTORS "AS IS" AND ANY 19 | # EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | # DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY 22 | # DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 25 | # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 27 | # THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | 29 | # Script to sort "children" and "files" sections in Xcode project.pbxproj files 30 | 31 | use strict; 32 | use warnings; 33 | 34 | use File::Basename; 35 | use File::Spec; 36 | use File::Temp qw(tempfile); 37 | use Getopt::Long; 38 | use File::Compare; 39 | 40 | sub sortChildrenByFileName($$); 41 | sub sortFilesByFileName($$); 42 | 43 | # Files (or products) without extensions 44 | my %isFile = map { $_ => 1 } qw( 45 | create_hash_table 46 | jsc 47 | minidom 48 | testapi 49 | testjsglue 50 | ); 51 | 52 | my $printWarnings = 1; 53 | my $showHelp; 54 | 55 | my $getOptionsResult = GetOptions( 56 | 'h|help' => \$showHelp, 57 | 'w|warnings!' => \$printWarnings, 58 | ); 59 | 60 | if (scalar(@ARGV) == 0 && !$showHelp) { 61 | print STDERR "ERROR: No Xcode project files (project.pbxproj) listed on command-line.\n"; 62 | undef $getOptionsResult; 63 | } 64 | 65 | if (!$getOptionsResult || $showHelp) { 66 | print STDERR <<__END__; 67 | Usage: @{[ basename($0) ]} [options] path/to/project.pbxproj [path/to/project.pbxproj ...] 68 | -h|--help show this help message 69 | -w|--[no-]warnings show or suppress warnings (default: show warnings) 70 | __END__ 71 | exit 1; 72 | } 73 | 74 | for my $projectFile (@ARGV) { 75 | if (basename($projectFile) =~ /\.xcodeproj$/) { 76 | $projectFile = File::Spec->catfile($projectFile, "project.pbxproj"); 77 | } 78 | 79 | if (basename($projectFile) ne "project.pbxproj") { 80 | print STDERR "WARNING: Not an Xcode project file: $projectFile\n" if $printWarnings; 81 | next; 82 | } 83 | 84 | # Grab the mainGroup for the project file 85 | my $mainGroup = ""; 86 | open(IN, "< $projectFile") || die "Could not open $projectFile: $!"; 87 | while (my $line = ) { 88 | $mainGroup = $2 if $line =~ m#^(\s*)mainGroup = ([0-9A-F]{24} /\* .+ \*/);$#; 89 | } 90 | close(IN); 91 | 92 | my ($OUT, $tempFileName) = tempfile( 93 | basename($projectFile) . "-XXXXXXXX", 94 | DIR => dirname($projectFile), 95 | UNLINK => 0, 96 | ); 97 | 98 | # Clean up temp file in case of die() 99 | $SIG{__DIE__} = sub { 100 | close(IN); 101 | close($OUT); 102 | unlink($tempFileName); 103 | }; 104 | 105 | my @lastTwo = (); 106 | open(IN, "< $projectFile") || die "Could not open $projectFile: $!"; 107 | while (my $line = ) { 108 | if ($line =~ /^(\s*)files = \(\s*$/) { 109 | print $OUT $line; 110 | my $endMarker = $1 . ");"; 111 | my @files; 112 | while (my $fileLine = ) { 113 | if ($fileLine =~ /^\Q$endMarker\E\s*$/) { 114 | $endMarker = $fileLine; 115 | last; 116 | } 117 | push @files, $fileLine; 118 | } 119 | print $OUT sort sortFilesByFileName @files; 120 | print $OUT $endMarker; 121 | } elsif ($line =~ /^(\s*)children = \(\s*$/) { 122 | print $OUT $line; 123 | my $endMarker = $1 . ");"; 124 | my @children; 125 | while (my $childLine = ) { 126 | if ($childLine =~ /^\Q$endMarker\E\s*$/) { 127 | $endMarker = $childLine; 128 | last; 129 | } 130 | push @children, $childLine; 131 | } 132 | if ($lastTwo[0] =~ m#^\s+\Q$mainGroup\E = \{$#) { 133 | # Don't sort mainGroup 134 | print $OUT @children; 135 | } else { 136 | print $OUT sort sortChildrenByFileName @children; 137 | } 138 | print $OUT $endMarker; 139 | } else { 140 | print $OUT $line; 141 | } 142 | 143 | push @lastTwo, $line; 144 | shift @lastTwo if scalar(@lastTwo) > 2; 145 | } 146 | close(IN); 147 | close($OUT); 148 | 149 | if (compare("$projectFile", "$tempFileName") == 0) { 150 | print STDERR "Project is already sorted\n"; 151 | unlink($tempFileName) || die "Could not delete $tempFileName: $!"; 152 | } else { 153 | print STDERR "Project was successfully sorted 👍\n"; 154 | unlink($projectFile) || die "Could not delete $projectFile: $!"; 155 | rename($tempFileName, $projectFile) || die "Could not rename $tempFileName to $projectFile: $!"; 156 | } 157 | } 158 | 159 | exit 0; 160 | 161 | sub sortChildrenByFileName($$) 162 | { 163 | my ($a, $b) = @_; 164 | my $aFileName = $1 if $a =~ /^\s*[A-Z0-9]{24} \/\* (.+) \*\/,$/; 165 | my $bFileName = $1 if $b =~ /^\s*[A-Z0-9]{24} \/\* (.+) \*\/,$/; 166 | my $aSuffix = $1 if $aFileName =~ m/\.([^.]+)$/; 167 | my $bSuffix = $1 if $bFileName =~ m/\.([^.]+)$/; 168 | if ((!$aSuffix && !$isFile{$aFileName} && $bSuffix) || ($aSuffix && !$bSuffix && !$isFile{$bFileName})) { 169 | return !$aSuffix ? -1 : 1; 170 | } 171 | if ($aFileName =~ /^UnifiedSource\d+/ && $bFileName =~ /^UnifiedSource\d+/) { 172 | my $aNumber = $1 if $aFileName =~ /^UnifiedSource(\d+)/; 173 | my $bNumber = $1 if $bFileName =~ /^UnifiedSource(\d+)/; 174 | return $aNumber <=> $bNumber; 175 | } 176 | return lc($aFileName) cmp lc($bFileName); 177 | } 178 | 179 | sub sortFilesByFileName($$) 180 | { 181 | my ($a, $b) = @_; 182 | my $aFileName = $1 if $a =~ /^\s*[A-Z0-9]{24} \/\* (.+) in /; 183 | my $bFileName = $1 if $b =~ /^\s*[A-Z0-9]{24} \/\* (.+) in /; 184 | if ($aFileName =~ /^UnifiedSource\d+/ && $bFileName =~ /^UnifiedSource\d+/) { 185 | my $aNumber = $1 if $aFileName =~ /^UnifiedSource(\d+)/; 186 | my $bNumber = $1 if $bFileName =~ /^UnifiedSource(\d+)/; 187 | return $aNumber <=> $bNumber; 188 | } 189 | return lc($aFileName) cmp lc($bFileName); 190 | } 191 | -------------------------------------------------------------------------------- /SimpleMath.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /SimpleMath.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /SimpleMath.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "CornerStacks", 6 | "repositoryURL": "https://github.com/filiplazov/CornerStacks.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "ab944879891c48ff53459a14c813b139bd24fa17", 10 | "version": "0.2.0" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /SimpleMath.xcodeproj/xcshareddata/xcschemes/SimpleMath.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 34 | 40 | 41 | 42 | 43 | 44 | 54 | 56 | 62 | 63 | 64 | 65 | 71 | 73 | 79 | 80 | 81 | 82 | 84 | 85 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /SimpleMath/App/App.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import UIKit 6 | import Combine 7 | 8 | final class App { 9 | // swiftlint:disable:next weak_delegate 10 | private var recognizerDelegate = RecognizerDelegate() 11 | private var keyboardObserver = KeyboardObserver() 12 | private var gesture: AnyGestureRecognizer? 13 | private let storage: Storage 14 | private var settings: Settings 15 | weak var window: UIWindow? 16 | 17 | init(window: UIWindow) { 18 | self.window = window 19 | storage = UserDefaultsStorage(withKey: App.identifier, modelVersion: App.version) 20 | settings = StoredSettings(withStorage: storage) 21 | } 22 | 23 | func startApp() { 24 | print("App version: \(App.version)") 25 | let contentView = ContentView() 26 | .environmentObject(SimpleMathViewModel(settings: settings)) 27 | .environmentObject(SettingsViewModel(settings: settings)) 28 | .environmentObject(Onboarding(withStorage: storage)) 29 | 30 | let controller = HostingController(rootView: contentView) 31 | window?.rootViewController = controller 32 | UITextField.appearance().tintColor = .primaryText 33 | 34 | gesture = AnyGestureRecognizer(target: window, action: #selector(UIView.endEditing)) 35 | gesture?.requiresExclusiveTouchType = false 36 | gesture?.cancelsTouchesInView = false 37 | gesture?.delegate = recognizerDelegate 38 | 39 | keyboardObserver.onShow = { [weak self] in 40 | guard let gesture = self?.gesture else { return } 41 | self?.window?.addGestureRecognizer(gesture) 42 | } 43 | 44 | keyboardObserver.onHide = { [weak self] in 45 | guard let gesture = self?.gesture else { return } 46 | self?.window?.removeGestureRecognizer(gesture) 47 | } 48 | window?.makeKeyAndVisible() 49 | } 50 | } 51 | 52 | extension App { 53 | static var version: String { 54 | Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" 55 | } 56 | 57 | static var identifier: String { 58 | Bundle.main.bundleIdentifier ?? "" 59 | } 60 | } 61 | 62 | private class KeyboardObserver { 63 | private var subcriptions = Set() 64 | var onShow: (() -> Void)? 65 | var onHide: (() -> Void)? 66 | 67 | init() { 68 | NotificationCenter.default 69 | .publisher(for: UIResponder.keyboardDidShowNotification) 70 | .sink(receiveValue: { [weak self] _ in 71 | self?.onShow?() 72 | }) 73 | .store(in: &subcriptions) 74 | 75 | NotificationCenter.default 76 | .publisher(for: UIResponder.keyboardDidHideNotification) 77 | .sink(receiveValue: { [weak self] _ in 78 | self?.onHide?() 79 | }) 80 | .store(in: &subcriptions) 81 | 82 | } 83 | } 84 | 85 | class AnyGestureRecognizer: UIGestureRecognizer { 86 | override func touchesBegan(_ touches: Set, with event: UIEvent) { 87 | state = .began 88 | } 89 | 90 | override func touchesEnded(_ touches: Set, with event: UIEvent?) { 91 | state = .ended 92 | } 93 | 94 | override func touchesCancelled(_ touches: Set, with event: UIEvent) { 95 | state = .cancelled 96 | } 97 | } 98 | 99 | private final class RecognizerDelegate: NSObject, UIGestureRecognizerDelegate { 100 | func gestureRecognizer( 101 | _ gestureRecognizer: UIGestureRecognizer, 102 | shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer 103 | ) -> Bool { 104 | return true 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /SimpleMath/App/Equations/Equation.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | struct Equation: Equatable { 6 | private var answer: Int? 7 | private let op: Operator //swiftlint:disable:this identifier_name 8 | private let answerDigitLimit: Int 9 | var type: EquationType { op.equationType } 10 | let left: Int 11 | let right: Int 12 | var question: String { "\(left) \(op.symbol) \(right) = " } 13 | var correctAnswer: Int { op(left, right) } 14 | var currentAnswerText: String { answer?.description ?? "" } 15 | var finishedAnswering = false 16 | var correctlyAnswered: Bool { answer == correctAnswer } 17 | var hasValidAnswer: Bool { answer != nil } 18 | 19 | init(left: Int, right: Int, operator: Operator, answerDigitLimit: Int) { 20 | op = `operator` 21 | self.left = left 22 | self.right = right 23 | self.answerDigitLimit = answerDigitLimit 24 | } 25 | 26 | mutating func append(digit: Int) { 27 | if let current = answer { 28 | guard current < 10.toThePower(of: answerDigitLimit - 1) else { return } 29 | answer = current * 10 + digit 30 | } else { 31 | answer = digit 32 | } 33 | } 34 | 35 | mutating func erase() { 36 | guard let current = answer else { return } 37 | answer = current > 9 ? current / 10 : nil 38 | } 39 | 40 | mutating func evaluate() { 41 | if answer != nil { 42 | finishedAnswering = true 43 | } 44 | } 45 | } 46 | 47 | extension Array where Element == Equation { 48 | var answeredAllCorrectly: Bool { 49 | self.allSatisfy { $0.correctlyAnswered && $0.finishedAnswering } 50 | } 51 | } 52 | 53 | private extension Int { 54 | func toThePower(of power: Int) -> Int { 55 | var answer = 1 56 | for _ in 0.. 10 | } 11 | -------------------------------------------------------------------------------- /SimpleMath/App/Equations/EquationType.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | enum EquationType: String, Codable, CaseIterable { 6 | case addition 7 | case subtraction 8 | case multiplication 9 | case division 10 | } 11 | -------------------------------------------------------------------------------- /SimpleMath/App/Equations/EquationsFactory.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | protocol EquationsFactory { 6 | func makeEquations(usingSettings: EquationSettings) -> GeneratedResult 7 | } 8 | -------------------------------------------------------------------------------- /SimpleMath/App/Equations/EquationsFactoryImp.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | struct EquationsFactoryImp: EquationsFactory { 6 | func makeEquations(usingSettings settings: EquationSettings) -> GeneratedResult { 7 | let maxAnswerDigits = maxAnswerDigitCount(settings: settings) 8 | let maxOperandDigits = settings.maximumDigit.description.count 9 | let types = Array(settings.equationTypes).shuffled() 10 | let equations = (0.. Equation in 12 | let type = types[index % types.count] 13 | return type.makeEquation(setting: settings, maxAnswerDigits: maxAnswerDigits) 14 | }) 15 | return GeneratedResult(equations: equations, maxOperandDigits: maxOperandDigits, maxAnswerDigits: maxAnswerDigits) 16 | } 17 | 18 | private func maxAnswerDigitCount(settings: EquationSettings) -> Int { 19 | let maxAnswer: Int 20 | if settings.equationTypes.contains(.multiplication) { 21 | maxAnswer = settings.maximumDigit * settings.maximumDigit 22 | } else if settings.equationTypes.contains(.addition) { 23 | maxAnswer = settings.maximumDigit + settings.maximumDigit 24 | } else { 25 | maxAnswer = settings.maximumDigit 26 | } 27 | return maxAnswer.description.count 28 | } 29 | } 30 | 31 | private extension EquationType { 32 | func makeEquation(setting: EquationSettings, maxAnswerDigits: Int) -> Equation { 33 | switch self { 34 | case .addition: return makeAddition(settings: setting, maxAnswerDigits: maxAnswerDigits) 35 | case .subtraction: return makeSubtraction(settings: setting, maxAnswerDigits: maxAnswerDigits) 36 | case .multiplication: return makeMultiplication(settings: setting, maxAnswerDigits: maxAnswerDigits) 37 | case .division : return makeDivision(settings: setting, maxAnswerDigits: maxAnswerDigits) 38 | } 39 | } 40 | 41 | private func makeAddition(settings: EquationSettings, maxAnswerDigits: Int) -> Equation { 42 | let left = Int.random(in: settings.minimumDigit...settings.maximumDigit) 43 | let right = Int.random(in: settings.minimumDigit...settings.maximumDigit) 44 | return Equation(left: left, right: right, operator: .add, answerDigitLimit: maxAnswerDigits) 45 | } 46 | 47 | private func makeSubtraction(settings: EquationSettings, maxAnswerDigits: Int) -> Equation { 48 | let right = Int.random(in: settings.minimumDigit...settings.maximumDigit) 49 | let left = Int.random(in: right...settings.maximumDigit) 50 | return Equation(left: left, right: right, operator: .subtract, answerDigitLimit: maxAnswerDigits) 51 | } 52 | 53 | private func makeMultiplication(settings: EquationSettings, maxAnswerDigits: Int) -> Equation { 54 | let left = Int.random(in: settings.minimumDigit...settings.maximumDigit) 55 | let right = Int.random(in: settings.minimumDigit...settings.maximumDigit) 56 | return Equation(left: left, right: right, operator: .multiply, answerDigitLimit: maxAnswerDigits) 57 | } 58 | 59 | private func makeDivision(settings: EquationSettings, maxAnswerDigits: Int) -> Equation { 60 | // we group the possible dividends by prime and nonprime array of numbers 61 | let grouping = Dictionary(grouping: (settings.minimumDigit...settings.maximumDigit), by: \.isPrime) 62 | let left: Int 63 | if grouping[true] == nil { 64 | // if there are no prime numbers, than pick from one of the nonprime numbers 65 | left = grouping[false]?.randomElement() ?? settings.maximumDigit 66 | } else { 67 | // if `0`, get a prime number, otherwise get a nonprime, nonprime numbers have twice the chance, 68 | // this reduces boring equations 69 | let lottery = Int.random(in: 0...2) 70 | left = grouping[lottery == 0]?.randomElement() ?? settings.maximumDigit 71 | } 72 | // avoid division by 0 73 | let minDigit = settings.minimumDigit == 0 ? 1 : settings.minimumDigit 74 | let right: Int 75 | if left == 0 { 76 | // if `0` is the dividend, divide by any number in the valid range 77 | right = Int.random(in: minDigit...settings.maximumDigit) 78 | } else { 79 | // otherwise, pick a random divisor that results in a division without remainder 80 | let possibleDivisors = (minDigit...left).filter { left % $0 == 0 } 81 | right = possibleDivisors.randomElement() ?? settings.maximumDigit 82 | } 83 | return Equation(left: left, right: right, operator: .divide, answerDigitLimit: maxAnswerDigits) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /SimpleMath/App/Equations/EquationsFactoryMock.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | final class EquationsFactoryMock: EquationsFactory { 6 | private let results: GeneratedResult 7 | var equationSettings: EquationSettings? 8 | 9 | init(_ create: () -> GeneratedResult) { 10 | results = create() 11 | } 12 | 13 | func makeEquations(usingSettings: EquationSettings) -> GeneratedResult { 14 | equationSettings = usingSettings 15 | return results 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /SimpleMath/App/Equations/GeneratedResult.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | struct GeneratedResult { 6 | let equations: [Equation] 7 | let maxOperandDigits: Int 8 | let maxAnswerDigits: Int 9 | } 10 | -------------------------------------------------------------------------------- /SimpleMath/App/Equations/Int+IsPrime.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import Foundation 6 | 7 | extension Int { 8 | var isPrime: Bool { 9 | guard self >= 2 else { return false } 10 | guard self != 2 else { return true } 11 | guard self % 2 != 0 else { return false } 12 | return !stride(from: 3, through: Int(sqrt(Double(self))), by: 2).contains { self % $0 == 0 } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /SimpleMath/App/Equations/Operator.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | struct Operator { 6 | let equationType: EquationType 7 | let symbol: String 8 | let function: (Int, Int) -> Int 9 | 10 | func callAsFunction(_ left: Int, _ right: Int) -> Int { 11 | function(left, right) 12 | } 13 | } 14 | 15 | extension Operator: Equatable { 16 | static func == (lhs: Operator, rhs: Operator) -> Bool { 17 | lhs.equationType == rhs.equationType && lhs.symbol == rhs.symbol 18 | } 19 | } 20 | 21 | extension Operator { 22 | static var add: Operator { Operator(equationType: .addition, symbol: "+", function: +) } 23 | static var subtract: Operator { Operator(equationType: .subtraction, symbol: "-", function: -) } 24 | static var multiply: Operator { Operator(equationType: .multiplication, symbol: "×", function: *) } 25 | static var divide: Operator { Operator(equationType: .division, symbol: "÷", function: /) } 26 | } 27 | -------------------------------------------------------------------------------- /SimpleMath/App/Equations/SettingsBundle+EquationSettings.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | extension SettingsBundle { 6 | var equationSettings: EquationSettings { 7 | EquationSettings( 8 | minimumDigit: minimumDigit, 9 | maximumDigit: maximumDigit, 10 | equationsCount: equationsCount, 11 | equationTypes: equationTypes 12 | ) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /SimpleMath/App/Helpers/AnyPublisher+Just.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import Combine 6 | 7 | extension AnyPublisher { 8 | static func just(_ value: Output) -> AnyPublisher { 9 | Just(value).eraseToAnyPublisher() 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /SimpleMath/App/Helpers/Assign+WeakCapture.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import Combine 6 | 7 | // This is a workaround to avoid strong self capturing (and causing retain cycle) when using assign on self 8 | // and storing subscription in self 9 | // https://forums.swift.org/t/does-assign-to-produce-memory-leaks/29546 10 | 11 | extension Publisher where Failure == Never { 12 | func assign(to keyPath: ReferenceWritableKeyPath, on root: Root) -> AnyCancellable { 13 | sink { [weak root] in 14 | root?[keyPath: keyPath] = $0 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /SimpleMath/App/Onboarding.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import Combine 6 | 7 | final class Onboarding: ObservableObject { 8 | private let storage: Storage 9 | @Published private(set) var showSettingsHint: Bool 10 | 11 | init(withStorage storage: Storage) { 12 | self.storage = storage 13 | showSettingsHint = !storage.loadOnboardingBundle().seenSettingsHint 14 | } 15 | 16 | func discardSettingsHint() { 17 | guard showSettingsHint else { return } 18 | showSettingsHint = false 19 | let bundle = OnboardingBundle(seenSettingsHint: !showSettingsHint) 20 | storage.store(onboardingBundle: bundle) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /SimpleMath/App/OnboardingBundle.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | struct OnboardingBundle: Equatable, Codable { 6 | var seenSettingsHint: Bool 7 | } 8 | 9 | extension OnboardingBundle { 10 | static var `default`: OnboardingBundle { 11 | OnboardingBundle(seenSettingsHint: false) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /SimpleMath/App/SettingsViewModel.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import Combine 6 | 7 | final class SettingsViewModel: ObservableObject { 8 | private weak var settings: Settings? 9 | private var subscriptions = Set() 10 | @Published private var minRange: Int? 11 | @Published private var maxRange: Int? 12 | @Published private var enabledEquationTypes = Set() 13 | @Published private(set) var minRangeText = "" 14 | @Published private(set) var maxRangeText = "" 15 | @Published private(set) var isRangeValid = true 16 | @Published private(set) var numberOfEquations = 0 17 | @Published private(set) var additionEnabled = false 18 | @Published private(set) var subtractonEnabled = false 19 | @Published private(set) var multiplicationEnabled = false 20 | @Published private(set) var divisionEnabled = false 21 | @Published var areSoundsEnabled = true 22 | 23 | init(settings: Settings) { 24 | self.settings = settings 25 | setupSubscriptions() 26 | } 27 | 28 | private func setupSubscriptions() { 29 | settings?.currentSettings 30 | .sink(receiveValue: { [weak self] settingsBundle in 31 | self?.minRange = settingsBundle.minimumDigit 32 | self?.maxRange = settingsBundle.maximumDigit 33 | self?.enabledEquationTypes = Set(settingsBundle.equationTypes) 34 | self?.numberOfEquations = settingsBundle.equationsCount 35 | self?.areSoundsEnabled = settingsBundle.areSoundsEnabled 36 | }) 37 | .store(in: &subscriptions) 38 | $minRange 39 | .map { $0?.description ?? "" } 40 | .assign(to: \.minRangeText, on: self) 41 | .store(in: &subscriptions) 42 | $maxRange 43 | .map { $0?.description ?? "" } 44 | .assign(to: \.maxRangeText, on: self) 45 | .store(in: &subscriptions) 46 | $minRange 47 | .combineLatest($maxRange) 48 | .map({ min, max in 49 | guard let min = min, let max = max else { return false } 50 | return min < max 51 | }) 52 | .assign(to: \.isRangeValid, on: self) 53 | .store(in: &subscriptions) 54 | $enabledEquationTypes 55 | .map { $0.contains(.addition) } 56 | .assign(to: \.additionEnabled, on: self) 57 | .store(in: &subscriptions) 58 | $enabledEquationTypes 59 | .map { $0.contains(.subtraction) } 60 | .assign(to: \.subtractonEnabled, on: self) 61 | .store(in: &subscriptions) 62 | $enabledEquationTypes 63 | .map { $0.contains(.multiplication) } 64 | .assign(to: \.multiplicationEnabled, on: self) 65 | .store(in: &subscriptions) 66 | $enabledEquationTypes 67 | .map { $0.contains(.division) } 68 | .assign(to: \.divisionEnabled, on: self) 69 | .store(in: &subscriptions) 70 | } 71 | 72 | func updateMinRange(text: String) { 73 | minRange = Int(text).flatMap { number in number < 100 && number >= 0 ? number : minRange } 74 | } 75 | 76 | func updateMaxRange(text: String) { 77 | maxRange = Int(text).flatMap { number in number < 100 && number >= 0 ? number : maxRange } 78 | } 79 | 80 | func decreaseNumberOfEquations() { 81 | guard numberOfEquations > 5 else { return } 82 | numberOfEquations -= 1 83 | } 84 | 85 | func increaseNumberOfEquations() { 86 | guard numberOfEquations < 30 else { return } 87 | numberOfEquations += 1 88 | } 89 | 90 | func enableEquation(type: EquationType) { 91 | enabledEquationTypes.insert(type) 92 | } 93 | 94 | func disableEquation(type: EquationType) { 95 | guard enabledEquationTypes.count > 1 && enabledEquationTypes.contains(type) else { return } 96 | enabledEquationTypes.remove(type) 97 | } 98 | 99 | func updateSoundsEnabled(to areEnabled: Bool) { 100 | areSoundsEnabled = areEnabled 101 | } 102 | 103 | func commitChanges() { 104 | guard let min = minRange, let max = maxRange else { return } 105 | let settingsBundle = SettingsBundle( 106 | minimumDigit: min, 107 | maximumDigit: max, 108 | equationsCount: numberOfEquations, 109 | equationTypes: enabledEquationTypes, 110 | areSoundsEnabled: areSoundsEnabled 111 | ) 112 | settings?.updateSettings(bundle: settingsBundle) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /SimpleMath/App/SimpleMathViewModel.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import Combine 6 | import UIKit 7 | 8 | final class SimpleMathViewModel: ObservableObject { 9 | private var subscriptions = Set() 10 | private var audioEngine: AudioEngine 11 | private weak var settings: Settings? 12 | private var areSoundsEnabled = false 13 | private var equationSettings: EquationSettings? 14 | let equationsFactory: EquationsFactory 15 | @Published private(set) var equations: [Equation] = [] 16 | @Published private(set) var currentEquationIndex = 0 17 | @Published private(set) var commandsAvailable = false 18 | @Published private(set) var operandDigitCount = 0 19 | @Published private(set) var answerDigitCount = 0 20 | @Published private(set) var correctAnswers = 0 21 | @Published private(set) var progress = 0.0 22 | @Published private(set) var finished = false 23 | @Published private(set) var greatSuccess = false 24 | var wrongAnswers: Int { equations.count - correctAnswers } 25 | 26 | init( 27 | settings: Settings, 28 | audioPlayer: AudioEngine = AudioPlayer(), 29 | equationsFactory: EquationsFactory = EquationsFactoryImp() 30 | ) { 31 | self.settings = settings 32 | self.audioEngine = audioPlayer 33 | self.equationsFactory = equationsFactory 34 | setupSubscriptions() 35 | } 36 | 37 | private func setupSubscriptions() { 38 | settings?.currentSettings 39 | .map(\.areSoundsEnabled) 40 | .assign(to: \.areSoundsEnabled, on: self) 41 | .store(in: &subscriptions) 42 | settings?.currentSettings 43 | .map(\.equationSettings) 44 | .removeDuplicates() 45 | .sink(receiveValue: makeNewEquations(withEquationSettings:)) 46 | .store(in: &subscriptions) 47 | 48 | $equations 49 | .filter { !$0.isEmpty } 50 | .map { $0.allSatisfy { $0.finishedAnswering } } 51 | .assign(to: \.finished, on: self) 52 | .store(in: &subscriptions) 53 | $equations 54 | .map { equations -> Int in equations.filter { $0.correctlyAnswered && $0.finishedAnswering }.count } 55 | .assign(to: \.correctAnswers, on: self) 56 | .store(in: &subscriptions) 57 | $equations 58 | .filter { !$0.isEmpty } 59 | .map { equations in Double(equations.filter { $0.finishedAnswering }.count) / Double(equations.count) } 60 | .assign(to: \.progress, on: self) 61 | .store(in: &subscriptions) 62 | $equations 63 | .filter { !$0.isEmpty } 64 | .combineLatest($currentEquationIndex) 65 | .map { (equations: [Equation], index: Int) -> Bool in equations[index].hasValidAnswer } 66 | .assign(to: \.commandsAvailable, on: self) 67 | .store(in: &subscriptions) 68 | $equations 69 | .filter { !$0.isEmpty } 70 | .map(\.answeredAllCorrectly) 71 | .assign(to: \.greatSuccess, on: self) 72 | .store(in: &subscriptions) 73 | } 74 | 75 | func input(number: Int) { 76 | equations[currentEquationIndex].append(digit: number) 77 | } 78 | 79 | func erase() { 80 | equations[currentEquationIndex].erase() 81 | } 82 | 83 | func evaluate() { 84 | equations[currentEquationIndex].evaluate() 85 | if areSoundsEnabled { 86 | if greatSuccess { 87 | audioEngine.play(sound: .greatSuccess) 88 | } else if equations[currentEquationIndex].correctlyAnswered { 89 | audioEngine.play(sound: .success) 90 | } else { 91 | audioEngine.play(sound: .failure) 92 | } 93 | } 94 | if currentEquationIndex + 1 < equations.count { 95 | currentEquationIndex += 1 96 | } 97 | } 98 | 99 | func reset() { 100 | guard let equationSettings = self.equationSettings else { return } 101 | makeNewEquations(withEquationSettings: equationSettings) 102 | } 103 | 104 | private func makeNewEquations(withEquationSettings equationSettings: EquationSettings) { 105 | self.equationSettings = equationSettings 106 | currentEquationIndex = 0 107 | let result = equationsFactory.makeEquations(usingSettings: equationSettings) 108 | equations = result.equations 109 | operandDigitCount = result.maxOperandDigits 110 | answerDigitCount = result.maxAnswerDigits 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /SimpleMath/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import UIKit 6 | 7 | @UIApplicationMain 8 | class AppDelegate: UIResponder, UIApplicationDelegate { 9 | 10 | func application( 11 | _ application: UIApplication, 12 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 13 | ) -> Bool { 14 | // Override point for customization after application launch. 15 | return true 16 | } 17 | 18 | // MARK: UISceneSession Lifecycle 19 | 20 | func application( 21 | _ application: UIApplication, 22 | configurationForConnecting connectingSceneSession: UISceneSession, 23 | options: UIScene.ConnectionOptions 24 | ) -> UISceneConfiguration { 25 | // Called when a new scene session is being created. 26 | // Use this method to select a configuration to create the new scene with. 27 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 28 | } 29 | 30 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { 31 | // Called when the user discards a scene session. 32 | // If any sessions were discarded while the application was not running, this will be called shortly after 33 | // application:didFinishLaunchingWithOptions. 34 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /SimpleMath/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Icon-Small-41.png", 5 | "idiom" : "iphone", 6 | "scale" : "2x", 7 | "size" : "20x20" 8 | }, 9 | { 10 | "filename" : "icon-60.png", 11 | "idiom" : "iphone", 12 | "scale" : "3x", 13 | "size" : "20x20" 14 | }, 15 | { 16 | "filename" : "Icon-Small@2x.png", 17 | "idiom" : "iphone", 18 | "scale" : "2x", 19 | "size" : "29x29" 20 | }, 21 | { 22 | "filename" : "Icon-Small@3x.png", 23 | "idiom" : "iphone", 24 | "scale" : "3x", 25 | "size" : "29x29" 26 | }, 27 | { 28 | "filename" : "Icon-Small-40@2x.png", 29 | "idiom" : "iphone", 30 | "scale" : "2x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "filename" : "Icon-Small-40@3x.png", 35 | "idiom" : "iphone", 36 | "scale" : "3x", 37 | "size" : "40x40" 38 | }, 39 | { 40 | "filename" : "Icon-60@2x.png", 41 | "idiom" : "iphone", 42 | "scale" : "2x", 43 | "size" : "60x60" 44 | }, 45 | { 46 | "filename" : "Icon-60@3x.png", 47 | "idiom" : "iphone", 48 | "scale" : "3x", 49 | "size" : "60x60" 50 | }, 51 | { 52 | "filename" : "icon-20.png", 53 | "idiom" : "ipad", 54 | "scale" : "1x", 55 | "size" : "20x20" 56 | }, 57 | { 58 | "filename" : "Icon-Small-42.png", 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "20x20" 62 | }, 63 | { 64 | "filename" : "Icon-Small.png", 65 | "idiom" : "ipad", 66 | "scale" : "1x", 67 | "size" : "29x29" 68 | }, 69 | { 70 | "filename" : "Icon-Small@2x-1.png", 71 | "idiom" : "ipad", 72 | "scale" : "2x", 73 | "size" : "29x29" 74 | }, 75 | { 76 | "filename" : "Icon-Small-40.png", 77 | "idiom" : "ipad", 78 | "scale" : "1x", 79 | "size" : "40x40" 80 | }, 81 | { 82 | "filename" : "Icon-Small-40@2x-1.png", 83 | "idiom" : "ipad", 84 | "scale" : "2x", 85 | "size" : "40x40" 86 | }, 87 | { 88 | "filename" : "Icon-76.png", 89 | "idiom" : "ipad", 90 | "scale" : "1x", 91 | "size" : "76x76" 92 | }, 93 | { 94 | "filename" : "Icon-76@2x.png", 95 | "idiom" : "ipad", 96 | "scale" : "2x", 97 | "size" : "76x76" 98 | }, 99 | { 100 | "filename" : "Icon-83.5@2x.png", 101 | "idiom" : "ipad", 102 | "scale" : "2x", 103 | "size" : "83.5x83.5" 104 | }, 105 | { 106 | "filename" : "iTunesArtwork@2x.png", 107 | "idiom" : "ios-marketing", 108 | "scale" : "1x", 109 | "size" : "1024x1024" 110 | } 111 | ], 112 | "info" : { 113 | "author" : "xcode", 114 | "version" : 1 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /SimpleMath/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filiplazov/SimpleMath/1d56a06d3ebf5ca7d01c4643581b4bf24078dfec/SimpleMath/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png -------------------------------------------------------------------------------- /SimpleMath/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filiplazov/SimpleMath/1d56a06d3ebf5ca7d01c4643581b4bf24078dfec/SimpleMath/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png -------------------------------------------------------------------------------- /SimpleMath/Assets.xcassets/AppIcon.appiconset/Icon-76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filiplazov/SimpleMath/1d56a06d3ebf5ca7d01c4643581b4bf24078dfec/SimpleMath/Assets.xcassets/AppIcon.appiconset/Icon-76.png -------------------------------------------------------------------------------- /SimpleMath/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filiplazov/SimpleMath/1d56a06d3ebf5ca7d01c4643581b4bf24078dfec/SimpleMath/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png -------------------------------------------------------------------------------- /SimpleMath/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filiplazov/SimpleMath/1d56a06d3ebf5ca7d01c4643581b4bf24078dfec/SimpleMath/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png -------------------------------------------------------------------------------- /SimpleMath/Assets.xcassets/AppIcon.appiconset/Icon-Small-40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filiplazov/SimpleMath/1d56a06d3ebf5ca7d01c4643581b4bf24078dfec/SimpleMath/Assets.xcassets/AppIcon.appiconset/Icon-Small-40.png -------------------------------------------------------------------------------- /SimpleMath/Assets.xcassets/AppIcon.appiconset/Icon-Small-40@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filiplazov/SimpleMath/1d56a06d3ebf5ca7d01c4643581b4bf24078dfec/SimpleMath/Assets.xcassets/AppIcon.appiconset/Icon-Small-40@2x-1.png -------------------------------------------------------------------------------- /SimpleMath/Assets.xcassets/AppIcon.appiconset/Icon-Small-40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filiplazov/SimpleMath/1d56a06d3ebf5ca7d01c4643581b4bf24078dfec/SimpleMath/Assets.xcassets/AppIcon.appiconset/Icon-Small-40@2x.png -------------------------------------------------------------------------------- /SimpleMath/Assets.xcassets/AppIcon.appiconset/Icon-Small-40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filiplazov/SimpleMath/1d56a06d3ebf5ca7d01c4643581b4bf24078dfec/SimpleMath/Assets.xcassets/AppIcon.appiconset/Icon-Small-40@3x.png -------------------------------------------------------------------------------- /SimpleMath/Assets.xcassets/AppIcon.appiconset/Icon-Small-41.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filiplazov/SimpleMath/1d56a06d3ebf5ca7d01c4643581b4bf24078dfec/SimpleMath/Assets.xcassets/AppIcon.appiconset/Icon-Small-41.png -------------------------------------------------------------------------------- /SimpleMath/Assets.xcassets/AppIcon.appiconset/Icon-Small-42.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filiplazov/SimpleMath/1d56a06d3ebf5ca7d01c4643581b4bf24078dfec/SimpleMath/Assets.xcassets/AppIcon.appiconset/Icon-Small-42.png -------------------------------------------------------------------------------- /SimpleMath/Assets.xcassets/AppIcon.appiconset/Icon-Small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filiplazov/SimpleMath/1d56a06d3ebf5ca7d01c4643581b4bf24078dfec/SimpleMath/Assets.xcassets/AppIcon.appiconset/Icon-Small.png -------------------------------------------------------------------------------- /SimpleMath/Assets.xcassets/AppIcon.appiconset/Icon-Small@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filiplazov/SimpleMath/1d56a06d3ebf5ca7d01c4643581b4bf24078dfec/SimpleMath/Assets.xcassets/AppIcon.appiconset/Icon-Small@2x-1.png -------------------------------------------------------------------------------- /SimpleMath/Assets.xcassets/AppIcon.appiconset/Icon-Small@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filiplazov/SimpleMath/1d56a06d3ebf5ca7d01c4643581b4bf24078dfec/SimpleMath/Assets.xcassets/AppIcon.appiconset/Icon-Small@2x.png -------------------------------------------------------------------------------- /SimpleMath/Assets.xcassets/AppIcon.appiconset/Icon-Small@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filiplazov/SimpleMath/1d56a06d3ebf5ca7d01c4643581b4bf24078dfec/SimpleMath/Assets.xcassets/AppIcon.appiconset/Icon-Small@3x.png -------------------------------------------------------------------------------- /SimpleMath/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filiplazov/SimpleMath/1d56a06d3ebf5ca7d01c4643581b4bf24078dfec/SimpleMath/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x.png -------------------------------------------------------------------------------- /SimpleMath/Assets.xcassets/AppIcon.appiconset/icon-20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filiplazov/SimpleMath/1d56a06d3ebf5ca7d01c4643581b4bf24078dfec/SimpleMath/Assets.xcassets/AppIcon.appiconset/icon-20.png -------------------------------------------------------------------------------- /SimpleMath/Assets.xcassets/AppIcon.appiconset/icon-60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filiplazov/SimpleMath/1d56a06d3ebf5ca7d01c4643581b4bf24078dfec/SimpleMath/Assets.xcassets/AppIcon.appiconset/icon-60.png -------------------------------------------------------------------------------- /SimpleMath/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /SimpleMath/Audio Engine/AudioEngine.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | protocol AudioEngine: AnyObject { 6 | func play(sound: Sound) 7 | } 8 | -------------------------------------------------------------------------------- /SimpleMath/Audio Engine/AudioEngineMock.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | final class AudioEngineMock: AudioEngine { 6 | var playedSounds: [Sound] = [] 7 | 8 | func play(sound: Sound) { 9 | playedSounds.append(sound) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /SimpleMath/Audio Engine/AudioPlayer.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import Foundation 6 | import AVFoundation 7 | 8 | final class AudioPlayer: AudioEngine { 9 | private var playerCache: [Sound: AVAudioPlayer] = [:] 10 | 11 | init() { 12 | cacheSounds() 13 | } 14 | 15 | private func load(sound: Sound) -> AVAudioPlayer? { 16 | guard let url = Bundle.main.url(forResource: sound.rawValue, withExtension: "m4a") else { return nil } 17 | 18 | do { 19 | try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) 20 | try AVAudioSession.sharedInstance().setActive(true) 21 | return try AVAudioPlayer(contentsOf: url, fileTypeHint: AVFileType.m4a.rawValue) 22 | 23 | } catch { 24 | print("error loading sound \(sound) : \(error.localizedDescription)") 25 | return nil 26 | } 27 | } 28 | 29 | private func cacheSounds() { 30 | DispatchQueue.global().async { [weak self] in 31 | for sound in Sound.allCases { 32 | guard let player = self?.load(sound: sound) else { continue } 33 | DispatchQueue.main.async { 34 | self?.playerCache[sound] = player 35 | } 36 | } 37 | } 38 | } 39 | 40 | func play(sound: Sound) { 41 | if let player = playerCache[sound] { 42 | player.pause() 43 | player.currentTime = 0 44 | player.play() 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /SimpleMath/Audio Engine/Sound Resources/failure.m4a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filiplazov/SimpleMath/1d56a06d3ebf5ca7d01c4643581b4bf24078dfec/SimpleMath/Audio Engine/Sound Resources/failure.m4a -------------------------------------------------------------------------------- /SimpleMath/Audio Engine/Sound Resources/great-success.m4a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filiplazov/SimpleMath/1d56a06d3ebf5ca7d01c4643581b4bf24078dfec/SimpleMath/Audio Engine/Sound Resources/great-success.m4a -------------------------------------------------------------------------------- /SimpleMath/Audio Engine/Sound Resources/success.m4a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filiplazov/SimpleMath/1d56a06d3ebf5ca7d01c4643581b4bf24078dfec/SimpleMath/Audio Engine/Sound Resources/success.m4a -------------------------------------------------------------------------------- /SimpleMath/Audio Engine/Sound.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | enum Sound: String, CaseIterable { 6 | case greatSuccess = "great-success" 7 | case success 8 | case failure 9 | } 10 | -------------------------------------------------------------------------------- /SimpleMath/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /SimpleMath/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 | $(MARKETING_VERSION) 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSceneManifest 24 | 25 | UIApplicationSupportsMultipleScenes 26 | 27 | UISceneConfigurations 28 | 29 | UIWindowSceneSessionRoleApplication 30 | 31 | 32 | UISceneConfigurationName 33 | Default Configuration 34 | UISceneDelegateClassName 35 | $(PRODUCT_MODULE_NAME).SceneDelegate 36 | 37 | 38 | 39 | 40 | UILaunchStoryboardName 41 | LaunchScreen 42 | UIRequiredDeviceCapabilities 43 | 44 | armv7 45 | 46 | UIRequiresFullScreen 47 | 48 | UIStatusBarStyle 49 | UIStatusBarStyleDefault 50 | UISupportedInterfaceOrientations 51 | 52 | UIInterfaceOrientationPortrait 53 | 54 | UISupportedInterfaceOrientations~ipad 55 | 56 | UIInterfaceOrientationPortrait 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /SimpleMath/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import UIKit 6 | import SwiftUI 7 | 8 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 9 | 10 | var window: UIWindow? 11 | var app: App? 12 | 13 | func scene( 14 | _ scene: UIScene, 15 | willConnectTo session: UISceneSession, 16 | options connectionOptions: UIScene.ConnectionOptions 17 | ) { 18 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. 19 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. 20 | // This delegate does not imply the connecting scene or session are new 21 | // (see `application:configurationForConnectingSceneSession` instead). 22 | if let windowScene = scene as? UIWindowScene { 23 | 24 | let window = UIWindow(windowScene: windowScene) 25 | self.window = window 26 | app = App(window: window) 27 | app?.startApp() 28 | } 29 | } 30 | 31 | func sceneDidDisconnect(_ scene: UIScene) { 32 | // Called as the scene is being released by the system. 33 | // This occurs shortly after the scene enters the background, or when its session is discarded. 34 | // Release any resources associated with this scene that can be re-created the next time the scene connects. 35 | // The scene may re-connect later, as its session was not neccessarily discarded 36 | // (see `application:didDiscardSceneSessions` instead). 37 | } 38 | 39 | func sceneDidBecomeActive(_ scene: UIScene) { 40 | // Called when the scene has moved from an inactive state to an active state. 41 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. 42 | } 43 | 44 | func sceneWillResignActive(_ scene: UIScene) { 45 | // Called when the scene will move from an active state to an inactive state. 46 | // This may occur due to temporary interruptions (ex. an incoming phone call). 47 | } 48 | 49 | func sceneWillEnterForeground(_ scene: UIScene) { 50 | // Called as the scene transitions from the background to the foreground. 51 | // Use this method to undo the changes made on entering the background. 52 | } 53 | 54 | func sceneDidEnterBackground(_ scene: UIScene) { 55 | // Called as the scene transitions from the foreground to the background. 56 | // Use this method to save data, release shared resources, and store enough scene-specific state information 57 | // to restore the scene back to its current state. 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /SimpleMath/Settings/Settings.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import Combine 6 | 7 | protocol Settings: AnyObject { 8 | var currentSettings: AnyPublisher { get } 9 | func updateSettings(bundle: SettingsBundle) 10 | } 11 | -------------------------------------------------------------------------------- /SimpleMath/Settings/SettingsBundle.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | struct SettingsBundle: Equatable, Codable { 6 | var minimumDigit: Int 7 | var maximumDigit: Int 8 | var equationsCount: Int 9 | var equationTypes: Set 10 | var areSoundsEnabled: Bool 11 | } 12 | 13 | extension SettingsBundle { 14 | static var `default`: SettingsBundle { 15 | SettingsBundle( 16 | minimumDigit: 0, 17 | maximumDigit: 9, 18 | equationsCount: 10, 19 | equationTypes: [.addition, .subtraction], 20 | areSoundsEnabled: true 21 | ) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /SimpleMath/Settings/SettingsMock.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import Combine 6 | 7 | final class SettingsMock: Settings { 8 | var updatedBundle: SettingsBundle? 9 | var currentSettings: AnyPublisher = Empty() 10 | .eraseToAnyPublisher() 11 | 12 | func updateSettings(bundle: SettingsBundle) { 13 | self.updatedBundle = bundle 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /SimpleMath/Settings/StoredSettings.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import Combine 6 | 7 | final class StoredSettings: Settings { 8 | private var publishSettings: CurrentValueSubject 9 | private var storage: Storage 10 | 11 | var currentSettings: AnyPublisher { 12 | publishSettings.eraseToAnyPublisher() 13 | } 14 | 15 | init(withStorage storage: Storage) { 16 | self.storage = storage 17 | let settingsBundle = storage.loadSettingsBundle() 18 | publishSettings = .init(settingsBundle) 19 | } 20 | 21 | func updateSettings(bundle: SettingsBundle) { 22 | guard bundle != publishSettings.value else { return } 23 | storage.store(settingsBundle: bundle) 24 | publishSettings.value = bundle 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /SimpleMath/Storage/Storage.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import Foundation 6 | 7 | protocol Storage: AnyObject { 8 | func store(settingsBundle: SettingsBundle) 9 | func loadSettingsBundle() -> SettingsBundle 10 | func store(onboardingBundle: OnboardingBundle) 11 | func loadOnboardingBundle() -> OnboardingBundle 12 | } 13 | -------------------------------------------------------------------------------- /SimpleMath/Storage/StorageMock.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | final class StorageMock: Storage { 6 | var settingsBundle: SettingsBundle 7 | var onboardingBundle: OnboardingBundle 8 | 9 | init(settingsBundle: SettingsBundle = .default, onboardingBundle: OnboardingBundle = .default) { 10 | self.settingsBundle = settingsBundle 11 | self.onboardingBundle = onboardingBundle 12 | } 13 | 14 | func store(settingsBundle: SettingsBundle) { 15 | self.settingsBundle = settingsBundle 16 | } 17 | 18 | func loadSettingsBundle() -> SettingsBundle { 19 | settingsBundle 20 | } 21 | 22 | func store(onboardingBundle: OnboardingBundle) { 23 | self.onboardingBundle = onboardingBundle 24 | } 25 | 26 | func loadOnboardingBundle() -> OnboardingBundle { 27 | onboardingBundle 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /SimpleMath/Storage/UserDefaultsStorage.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import Foundation 6 | 7 | final class UserDefaultsStorage: Storage { 8 | private var storedDataCache: StoredData? 9 | let modelVersion: String 10 | let key: String 11 | 12 | init(withKey: String, modelVersion: String) { 13 | key = withKey 14 | self.modelVersion = modelVersion 15 | } 16 | 17 | func store(settingsBundle: SettingsBundle) { 18 | var data = loadStoredData() 19 | data.settingsBundle = settingsBundle 20 | save(storedData: data) 21 | 22 | } 23 | 24 | func loadSettingsBundle() -> SettingsBundle { 25 | loadStoredData().settingsBundle 26 | } 27 | 28 | func store(onboardingBundle: OnboardingBundle) { 29 | var data = loadStoredData() 30 | data.onboardingBundle = onboardingBundle 31 | save(storedData: data) 32 | } 33 | 34 | func loadOnboardingBundle() -> OnboardingBundle { 35 | loadStoredData().onboardingBundle 36 | } 37 | 38 | private func loadStoredData() -> StoredData { 39 | if let cache = storedDataCache { 40 | return cache 41 | } else { 42 | var storedData: StoredData 43 | do { 44 | if let data = UserDefaults.standard.value(forKey: key) as? Data { 45 | storedData = try JSONDecoder().decode(StoredData.self, from: data) 46 | } else { 47 | print("No data found for key \(key), generating default values") 48 | storedData = .useDefault 49 | storedData.modelVersion = modelVersion 50 | } 51 | } catch { 52 | print("error reading stored data \(error), generating default values") 53 | storedData = .useDefault 54 | storedData.modelVersion = modelVersion 55 | } 56 | storedDataCache = storedData 57 | return storedData 58 | } 59 | } 60 | 61 | private func save(storedData: StoredData) { 62 | storedDataCache = storedData 63 | do { 64 | try UserDefaults.standard.set(JSONEncoder().encode(storedData), forKey: key) 65 | } catch { 66 | print("failed saving data :", error) 67 | } 68 | } 69 | } 70 | 71 | // the model version is stored if migrations are needed in the future 72 | private struct StoredData: Codable { 73 | var modelVersion: String 74 | var settingsBundle: SettingsBundle 75 | var onboardingBundle: OnboardingBundle 76 | } 77 | 78 | extension StoredData { 79 | static var useDefault: StoredData { 80 | StoredData(modelVersion: "", settingsBundle: .default, onboardingBundle: .default) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /SimpleMath/UI/ContentView.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import CornerStacks 6 | import SwiftUI 7 | 8 | struct ContentView: View { 9 | @ObservedObject private var resultsSheet = ModalPesentation() 10 | @EnvironmentObject private var viewModel: SimpleMathViewModel 11 | @EnvironmentObject private var onboarding: Onboarding 12 | @State private var showSettings = false 13 | @State private var showResults = false 14 | @Environment(\.horizontalSizeClass) private var hSizeClass 15 | private var additionalTopPadding: CGFloat { CGFloat.screenHeight >= 736 ? 20 : 0 } 16 | private var progressWidth: CGFloat { (hSizeClass.isRegular ? 80 : 40) * (showSettings ? 1.5 : 1) } 17 | 18 | var body: some View { 19 | ZStack { 20 | Color.background 21 | .edgesIgnoringSafeArea(.all) 22 | 23 | // measuring the safe area screen 24 | GeometryReader { proxy in 25 | ZStack { 26 | VStack(spacing: 0) { 27 | Spacer() 28 | 29 | EquationsView(maxWidth: proxy.size.width) 30 | .padding(.bottom, self.hSizeClass.isRegular ? 130 : proxy.size.height * 0.07) 31 | 32 | // input area takes lower 40% of the screen (within safe area) 33 | InputView(maxWidth: proxy.size.width) 34 | .frame(width: proxy.size.width, height: proxy.size.height * 0.4) 35 | } 36 | .blur(radius: self.showSettings ? 20 : 0) 37 | .disabled(self.showSettings) 38 | 39 | TopLeadingHStack { 40 | ProgressView( 41 | width: self.progressWidth, 42 | progress: self.viewModel.progress, 43 | play: self.showSettings, 44 | pulse: self.onboarding.showSettingsHint, 45 | action: { 46 | self.onboarding.discardSettingsHint() 47 | self.showSettings.toggle() 48 | } 49 | ) 50 | } 51 | .padding(.top, self.hSizeClass == .regular ? 24 : 10 + self.additionalTopPadding) 52 | .padding(.horizontal) 53 | 54 | CorrectAnswersView(correctAnswers: self.viewModel.correctAnswers) 55 | .padding(.top, self.hSizeClass == .regular ? 14 : 6 + self.additionalTopPadding) 56 | .padding(.horizontal) 57 | .blur(radius: self.showSettings ? 20 : 0) 58 | } 59 | } 60 | 61 | SettingsPanelView(show: $showSettings) 62 | } 63 | .onReceive(viewModel.$finished) { self.resultsSheet.isPresented = $0 } 64 | .onAppear { self.resultsSheet.onModalGestureDismiss(self.viewModel.reset) } 65 | .sheet(isPresented: $resultsSheet.isPresented) { 66 | ResultsView() 67 | .environmentObject(self.viewModel) 68 | .environmentObject(self.resultsSheet) 69 | } 70 | } 71 | } 72 | 73 | struct ContentView_Previews: PreviewProvider { 74 | static let storageMock = StorageMock(settingsBundle: .default, onboardingBundle: .default) 75 | static let settingsMock: Settings = { 76 | let settings = SettingsMock() 77 | var bundle = SettingsBundle.default 78 | bundle.areSoundsEnabled = false 79 | bundle.equationsCount = 20 80 | settings.currentSettings = .just(bundle) 81 | return settings 82 | }() 83 | 84 | static let factoryMock = EquationsFactoryMock { 85 | let onePlusOne = Equation(left: 1, right: 1, operator: .add, answerDigitLimit: 1) 86 | let equations: [Equation] = (1...5).map {_ in onePlusOne } 87 | return GeneratedResult( 88 | equations: (1...5).map {_ in onePlusOne }, 89 | maxOperandDigits: 1, 90 | maxAnswerDigits: 1 91 | ) 92 | } 93 | 94 | static let finishedViewModel: SimpleMathViewModel = { 95 | let viewModel = SimpleMathViewModel(settings: settingsMock) 96 | for _ in viewModel.equations { 97 | viewModel.input(number: 1) 98 | viewModel.evaluate() 99 | } 100 | return viewModel 101 | }() 102 | 103 | static let onboarding = Onboarding(withStorage: storageMock) 104 | 105 | static var previews: some View { 106 | Group { 107 | // ContentView() 108 | // .environmentObject(finishedViewModel) 109 | // .previewDevice(PreviewDevice(rawValue: "iPad Air (3rd generation)")) 110 | // .previewDisplayName("iPad Air (3rd generation)") 111 | // .environment(\.horizontalSizeClass, .regular) 112 | // 113 | // ContentView() 114 | // .environmentObject(SimpleMathViewModel(settings: Self.settingsMock)) 115 | // .previewDevice(PreviewDevice(rawValue: "iPhone SE")) 116 | // .previewDisplayName("iPhone SE") 117 | 118 | ContentView() 119 | .environmentObject(SimpleMathViewModel(settings: settingsMock)) 120 | .environmentObject(onboarding) 121 | .environmentObject(SettingsViewModel(settings: settingsMock)) 122 | .previewDevice(PreviewDevice(rawValue: "iPhone 8")) 123 | .previewDisplayName("iPhone 8") 124 | 125 | // ContentView() 126 | // .environmentObject(SimpleMathViewModel(settings: Self.settingsMock)) 127 | // .previewDevice(PreviewDevice(rawValue: "iPhone 11 Pro")) 128 | // .previewDisplayName("iPhone 11 Pro") 129 | // ContentView() 130 | // .environmentObject(SimpleMathViewModel(settings: Self.settingsMock)) 131 | // .previewDevice(PreviewDevice(rawValue: "iPhone 11 Pro Max")) 132 | // .previewDisplayName("iPhone 11 Pro Max") 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /SimpleMath/UI/Helpers/BoundsAnchorKey.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import SwiftUI 6 | 7 | struct BoundsAnchorKey: PreferenceKey { 8 | static var defaultValue: Anchor? 9 | static func reduce(value: inout Anchor?, nextValue: () -> Anchor?) { 10 | value = value ?? nextValue() 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /SimpleMath/UI/Helpers/Color+NamedColors.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import SwiftUI 6 | 7 | extension Color { 8 | static let background = Color(#colorLiteral(red: 0.5803921569, green: 0.1294117647, blue: 0.5725490196, alpha: 1)) 9 | static let primaryText = Color.white 10 | static let currentAnswer = Color(#colorLiteral(red: 0.2431372549, green: 0.4117647059, blue: 1, alpha: 1)) 11 | static let correctAnswer = Color(#colorLiteral(red: 0.4666666687, green: 0.7647058964, blue: 0.2666666806, alpha: 1)) 12 | static let incorrectAnswer = Color(#colorLiteral(red: 0.9372549057, green: 0.3490196168, blue: 0.1921568662, alpha: 1)) 13 | static let unanswered = Color(#colorLiteral(red: 0.501960814, green: 0.501960814, blue: 0.501960814, alpha: 1)) 14 | static let discard = Color(#colorLiteral(red: 0.9411764741, green: 0.4980392158, blue: 0.3529411852, alpha: 1)) 15 | static let confirm = Color(#colorLiteral(red: 0.9686274529, green: 0.78039217, blue: 0.3450980484, alpha: 1)) 16 | static let progressIncomplete = Color(#colorLiteral(red: 0.3870187126, green: 0.08371740527, blue: 0.3813446013, alpha: 1)) 17 | static let progressComplete = primaryText 18 | static let playPause = primaryText 19 | static let yellowStar = confirm 20 | static let miniDeviceFrame = Color.black 21 | static let miniDeviceHardwareStroke = unanswered 22 | static let miniDeviceInputMockupGray = Color(#colorLiteral(red: 0.4346842596, green: 0.4346842596, blue: 0.4346842596, alpha: 1)) 23 | static let miniDeviceInputMockupGreen = Color(#colorLiteral(red: 0.2297284843, green: 0.3980296213, blue: 0.1192763537, alpha: 1)) 24 | static let settingPanelBackgroundBase = Color.black 25 | static let settingCellBackgroundBase = Color.white 26 | static let settingTextFieldBorder = Color(#colorLiteral(red: 0.2286085633, green: 0.2460217858, blue: 0.2703869619, alpha: 1)) 27 | static let settingTextFieldBorderInvalid = Color(#colorLiteral(red: 0.6098763117, green: 0.01243970383, blue: 0.2637666402, alpha: 1)) 28 | static let settingEquationEnabled = primaryText 29 | static let settingEquationDisabled = Color(#colorLiteral(red: 0.501960814, green: 0.501960814, blue: 0.501960814, alpha: 1)) 30 | static let settingStepperBackground = Color(#colorLiteral(red: 0.2286085633, green: 0.2460217858, blue: 0.2703869619, alpha: 1)) 31 | } 32 | 33 | //this is temporary, should move all colors to assets soon 34 | extension UIColor { 35 | static let primaryText = UIColor.white 36 | } 37 | -------------------------------------------------------------------------------- /SimpleMath/UI/Helpers/HostingViewController.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import SwiftUI 6 | 7 | class HostingController: UIHostingController where Content: View { 8 | override var preferredStatusBarStyle: UIStatusBarStyle { 9 | return .lightContent 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /SimpleMath/UI/Helpers/Image+Symbol.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import SwiftUI 6 | 7 | extension Image { 8 | init(withSymbol symbol: Symbol) { 9 | self.init(systemName: symbol.rawValue) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /SimpleMath/UI/Helpers/ModalPresentation.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import Combine 6 | 7 | final class ModalPesentation: ObservableObject { 8 | private var subscriptions = Set() 9 | private var presentationChange = PassthroughSubject() 10 | private var dismissHandler: () -> Void = { } 11 | 12 | @Published var isPresented = false { 13 | didSet { presentationChange.send(isPresented) } 14 | } 15 | var willDismiss = false 16 | 17 | init() { 18 | presentationChange 19 | .removeDuplicates() 20 | .scan((false, false), { current, new in (current.1, new) }) // change tuple (wasVisible, isVisible) 21 | // swiftlint:disable:next identifier_name 22 | .filter({ from, to in from && !to }) // only allow events where modal was visible and now it is not 23 | .map { _ in () } // ignore the boolean and just publish void 24 | .filter(allowEvent) // prevent the event if `willDismiss` was explicitly set to true 25 | .sink(receiveValue: { [weak self] in 26 | self?.dismissHandler() 27 | }) 28 | .store(in: &subscriptions) 29 | } 30 | 31 | func onModalGestureDismiss(_ handler: @escaping () -> Void) { 32 | dismissHandler = handler 33 | } 34 | 35 | private func allowEvent() -> Bool { 36 | if willDismiss { 37 | willDismiss = false 38 | return false 39 | } 40 | return true 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /SimpleMath/UI/Helpers/Optional+UserInterfaceSizeClass.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import SwiftUI 6 | 7 | extension Optional where Wrapped == UserInterfaceSizeClass { 8 | var isRegular: Bool { 9 | self == .regular 10 | } 11 | 12 | var isCompact: Bool { 13 | self == .compact 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /SimpleMath/UI/Helpers/ScreenExtensions.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import UIKit 6 | 7 | extension CGSize { 8 | static let screen = UIScreen.main.bounds.size 9 | } 10 | 11 | extension CGFloat { 12 | static let screenWidth = CGSize.screen.width 13 | static let screenHeight = CGSize.screen.height 14 | } 15 | -------------------------------------------------------------------------------- /SimpleMath/UI/Helpers/SizeKey.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import SwiftUI 6 | 7 | struct SizeKey: PreferenceKey { 8 | static let defaultValue: CGSize = .zero 9 | static func reduce(value: inout CGSize, nextValue: () -> CGSize) { 10 | value = nextValue() 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /SimpleMath/UI/Helpers/Symbol.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | enum Symbol: String { 6 | case erase = "arrow.left.circle" 7 | case star = "star.fill" 8 | case reset = "arrow.counterclockwise.circle" 9 | case plus = "plus" 10 | case minus = "minus" 11 | case leftArrow = "arrow.left" 12 | case rightArrow = "arrow.right" 13 | case checkmarkFilled = "checkmark.circle.fill" 14 | case checkmark = "checkmark.circle" 15 | case soundsEnabled = "speaker.3" 16 | case soundsDisabled = "speaker.slash" 17 | } 18 | -------------------------------------------------------------------------------- /SimpleMath/UI/Helpers/View+Debug.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import SwiftUI 6 | 7 | #if DEBUG 8 | extension View { 9 | func blackBorder() -> some View { 10 | self.border(Color.black) 11 | } 12 | func redBorder() -> some View { 13 | self.border(Color.red) 14 | } 15 | func greenBorder() -> some View { 16 | self.border(Color.green) 17 | } 18 | 19 | func blackBackground() -> some View { 20 | self.background(Color.black) 21 | } 22 | 23 | func redBackground() -> some View { 24 | self.background(Color.red) 25 | } 26 | 27 | func greenBackground() -> some View { 28 | self.background(Color.green) 29 | } 30 | } 31 | 32 | // Credit: Geek & Dad, 33 | // https://geekanddad.wordpress.com/2020/02/13/swiftui-tiny-bits-little-view-extension-to-log-to-the-console/ 34 | extension View { 35 | func printMessage(_ messages: Any..., separator: String = " ", terminator: String = "\n") -> some View { 36 | for msg in messages { 37 | print(msg, separator, terminator: "") 38 | } 39 | print() 40 | return self 41 | } 42 | } 43 | 44 | extension View { 45 | func printFrame(label: String) -> some View { 46 | self 47 | .background( 48 | GeometryReader { proxy in 49 | Color.clear 50 | .printMessage(label, proxy.frame(in: .global)) 51 | }) 52 | } 53 | } 54 | #endif 55 | -------------------------------------------------------------------------------- /SimpleMath/UI/Main/CommandInputButton.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import SwiftUI 6 | 7 | struct CommandInputButton: View { 8 | let symbol: Symbol 9 | let color: Color 10 | let action: () -> Void 11 | let isEnabled: Bool 12 | 13 | var body: some View { 14 | Button(action: { withAnimation { self.action() } }, label: { Image(withSymbol: symbol) }) 15 | .foregroundColor(color.opacity(isEnabled ? 1.0 : 0.2)) 16 | .disabled(!isEnabled) 17 | .padding() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /SimpleMath/UI/Main/CorrectAnswersView.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import CornerStacks 6 | import SwiftUI 7 | 8 | struct CorrectAnswersView: View { 9 | @Environment(\.horizontalSizeClass) private var hSizeClass 10 | let correctAnswers: Int 11 | 12 | var body: some View { 13 | Group { 14 | if hSizeClass.isRegular { 15 | TopTrailingVStack(spacing: 10) { 16 | InnerContent(correctAnswers: correctAnswers, bigSize: true) 17 | } 18 | } else { 19 | TopTrailingHStack(spacing: 10) { 20 | InnerContent(correctAnswers: correctAnswers, bigSize: false) 21 | } 22 | } 23 | } 24 | .animation(nil) 25 | } 26 | } 27 | 28 | private struct InnerContent: View { 29 | let correctAnswers: Int 30 | let bigSize: Bool 31 | 32 | var body: some View { 33 | Group { 34 | Text(correctAnswers.description) 35 | .font(Font.system(size: bigSize ? 70 : 40, weight: .bold, design: .monospaced)) 36 | Image(withSymbol: .star) 37 | .font(Font.system(size: bigSize ? 50 : 30, weight: .bold, design: .monospaced)) 38 | } 39 | .opacity(correctAnswers > 0 ? 1 : 0) 40 | .foregroundColor(.correctAnswer) 41 | } 42 | } 43 | 44 | private extension Font { 45 | static func commandFont(for proxy: GeometryProxy, horizontalSizeClass: UserInterfaceSizeClass?) -> Font { 46 | Font.system(size: proxy.size.width * 0.08, weight: .heavy, design: .monospaced) 47 | } 48 | 49 | static func numbersFont(for proxy: GeometryProxy, horizontalSizeClass: UserInterfaceSizeClass?) -> Font { 50 | let scale: CGFloat = horizontalSizeClass == .regular ? 0.11 : 0.15 51 | return Font.system(size: proxy.size.width * scale, weight: .heavy, design: .monospaced) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /SimpleMath/UI/Main/EquationsView.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import SwiftUI 6 | 7 | struct EquationsView: View { 8 | @State private var equationSize: CGSize = CGSize(width: 20, height: 20) 9 | @Environment(\.horizontalSizeClass) private var hSizeClass 10 | @EnvironmentObject private var viewModel: SimpleMathViewModel 11 | let maxWidth: CGFloat 12 | 13 | var body: some View { 14 | ZStack { 15 | // fake invisible maximum character equation for measuring: operand + operand = answer 16 | HStack { 17 | Text(verbatim: .emptySpace(6) + .emptySpace(self.viewModel.operandDigitCount * 2)) 18 | Text(verbatim: .emptySpace(self.viewModel.answerDigitCount)) 19 | .frame(width: self.answerSlotWidth) 20 | .clipShape(Capsule()) 21 | } 22 | .font(.equationFont( 23 | for: maxWidth, 24 | horizontalSizeClass: hSizeClass, 25 | operandLength: viewModel.operandDigitCount, 26 | answerLength: viewModel.answerDigitCount) 27 | ) 28 | .background(GeometryReader { Color.clear.preference(key: SizeKey.self, value: $0.size) }) 29 | .onPreferenceChange(SizeKey.self) { self.equationSize = $0 } 30 | 31 | ForEach(0.. CGFloat { 53 | if index == viewModel.currentEquationIndex { 54 | return 0 55 | } else { 56 | return CGFloat(CGFloat((index - viewModel.currentEquationIndex)) * (equationSize.height + spacing)) 57 | } 58 | } 59 | 60 | private func opacity(forIndex index: Int) -> Double { 61 | let distance = abs(index - viewModel.currentEquationIndex) 62 | switch distance { 63 | case 0: return 1 64 | case 1: return 0.5 65 | case 2: return 0.3 66 | default: return 0 67 | } 68 | } 69 | 70 | private var spacing: CGFloat { 71 | hSizeClass == .regular ? 20 : 12 72 | } 73 | 74 | private var padding: CGFloat { 75 | hSizeClass == .regular ? 130 : 40 76 | } 77 | 78 | private var answerSlotWidth: CGFloat { 79 | switch viewModel.answerDigitCount { 80 | case 2: return hSizeClass.isRegular ? iPadSlotSize : 86 81 | case 3: return hSizeClass.isRegular ? iPadSlotSize : 90 82 | case 4: return hSizeClass.isRegular ? iPadSlotSize : 100 83 | default: return hSizeClass.isRegular ? iPadSlotSize : 100 84 | } 85 | } 86 | 87 | private var iPadSlotSize: CGFloat { 88 | switch viewModel.answerDigitCount { 89 | case 2: return CGFloat.screenWidth >= 1024 ? 160 : 130 90 | case 3: return CGFloat.screenWidth >= 1024 ? 196 : 156 91 | case 4: return CGFloat.screenWidth >= 1024 ? 220 : 180 92 | default: return CGFloat.screenWidth >= 1024 ? 150 : 130 93 | } 94 | } 95 | } 96 | 97 | private extension Font { 98 | static func equationFont( 99 | for maxWidth: CGFloat, 100 | horizontalSizeClass: UserInterfaceSizeClass?, 101 | operandLength: Int, 102 | answerLength: Int 103 | ) -> Font { 104 | let scale: CGFloat 105 | if horizontalSizeClass == .regular { 106 | if operandLength == 2 && answerLength == 4 { 107 | scale = 0.075 108 | } else { 109 | scale = 0.085 110 | } 111 | 112 | } else { 113 | if operandLength == 1 && (answerLength == 2 || answerLength == 1) { 114 | scale = 0.10 115 | } else if operandLength == 2 && answerLength == 2 { 116 | scale = 0.095 117 | } else if operandLength == 2 && answerLength == 3 { 118 | scale = 0.09 119 | } else { 120 | scale = 0.085 121 | } 122 | } 123 | return Font.system(size: maxWidth * scale, weight: .bold, design: .monospaced) 124 | } 125 | } 126 | 127 | private struct EquationRowView: View { 128 | let equation: Equation 129 | let isCurrentEquation: Bool 130 | let answerSlotWidth: CGFloat 131 | let opacity: Double 132 | 133 | var body: some View { 134 | HStack(spacing: 0) { 135 | Text(equation.question) 136 | Text(equation.currentAnswerText.isEmpty ? " " : equation.currentAnswerText) 137 | .animation(nil) 138 | .frame(width: answerSlotWidth) 139 | .background(evaluationColor.opacity(opacity)) 140 | .clipShape(Capsule()) 141 | } 142 | .foregroundColor(Color.primaryText.opacity(opacity)) 143 | } 144 | 145 | private var evaluationColor: Color { 146 | if equation.finishedAnswering { 147 | return equation.correctlyAnswered ? .correctAnswer : .incorrectAnswer 148 | } else { 149 | return isCurrentEquation ? .currentAnswer : .unanswered 150 | } 151 | } 152 | } 153 | 154 | extension StringProtocol { 155 | static func emptySpace(_ times: Int) -> String { 156 | String(repeating: " ", count: times) 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /SimpleMath/UI/Main/InputView.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import SwiftUI 6 | 7 | struct InputView: View { 8 | @Environment(\.horizontalSizeClass) private var hSizeClass 9 | @EnvironmentObject private var viewModel: SimpleMathViewModel 10 | let maxWidth: CGFloat 11 | 12 | var body: some View { 13 | HStack { 14 | CommandInputButton( 15 | symbol: .erase, 16 | color: .discard, 17 | action: viewModel.erase, 18 | isEnabled: viewModel.commandsAvailable 19 | ) 20 | .font(.commandFont(for: maxWidth, horizontalSizeClass: hSizeClass)) 21 | .padding(.horizontal, hSizeClass.isRegular ? 60 : 0) 22 | NumbersInputView(spacing: hSizeClass == .regular ? 80 : 48, action: viewModel.input(number:)) 23 | .font(.numbersFont(for: maxWidth, horizontalSizeClass: hSizeClass)) 24 | CommandInputButton( 25 | symbol: .checkmark, 26 | color: .confirm, 27 | action: viewModel.evaluate, 28 | isEnabled: viewModel.commandsAvailable 29 | ) 30 | .font(.commandFont(for: maxWidth, horizontalSizeClass: hSizeClass)) 31 | .padding(.horizontal, hSizeClass.isRegular ? 60 : 0) 32 | } 33 | } 34 | } 35 | 36 | private extension Font { 37 | static func commandFont(for width: CGFloat, horizontalSizeClass: UserInterfaceSizeClass?) -> Font { 38 | Font.system(size: width * 0.08, weight: .heavy, design: .monospaced) 39 | } 40 | 41 | static func numbersFont(for width: CGFloat, horizontalSizeClass: UserInterfaceSizeClass?) -> Font { 42 | let scale: CGFloat = horizontalSizeClass == .regular ? 0.11 : 0.15 43 | return Font.system(size: width * scale, weight: .heavy, design: .monospaced) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /SimpleMath/UI/Main/NumbersInputView.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import SwiftUI 6 | 7 | struct NumbersInputView: View { 8 | let spacing: CGFloat 9 | let action: (Int) -> Void 10 | 11 | var body: some View { 12 | VStack { 13 | HStack(spacing: self.spacing) { 14 | ForEach(1...3, id: \.self) { index in 15 | Button(index.description) { self.action(index) } 16 | } 17 | } 18 | HStack(spacing: self.spacing) { 19 | ForEach(4...6, id: \.self) { index in 20 | Button(index.description) { self.action(index) } 21 | } 22 | } 23 | HStack(spacing: self.spacing) { 24 | ForEach(7...9, id: \.self) { index in 25 | Button(index.description) { self.action(index) } 26 | } 27 | } 28 | HStack(spacing: self.spacing) { 29 | Button("0") { self.action(0) } 30 | } 31 | 32 | } 33 | .foregroundColor(.primaryText) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /SimpleMath/UI/Main/PlayPauseView.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info/ 4 | 5 | import SwiftUI 6 | 7 | struct PlayPauseView: View { 8 | private let shorterSize: CGFloat 9 | private let longerSize: CGFloat 10 | var play: Bool 11 | let width: CGFloat 12 | 13 | init(play: Bool, width: CGFloat) { 14 | self.play = play 15 | self.width = width 16 | shorterSize = width * 0.2 17 | longerSize = width * 0.8 18 | } 19 | 20 | var body: some View { 21 | ZStack { 22 | Tetragon(p1: CGPoint(x: play ? 1 : 0, y: 0), animateOn: \.p1.x) 23 | .frame(width: shorterSize, height: longerSize) 24 | .scaleEffect(CGSize(width: play ? 2 : 1, height: 1)) 25 | .offset(x: -shorterSize, y: 0) 26 | 27 | Tetragon(p2: CGPoint(x: play ? 0 : 1, y: 0), animateOn: \.p2.x) 28 | .frame(width: shorterSize, height: longerSize) 29 | .scaleEffect(CGSize(width: play ? 2 : 1, height: 1)) 30 | .offset(x: shorterSize, y: 0) 31 | 32 | } 33 | .frame(width: width, height: width) 34 | .rotationEffect(.degrees( play ? 90 : 0)) 35 | .animation(Animation.spring()) 36 | } 37 | } 38 | 39 | struct PlayPauseView_Previews: PreviewProvider { 40 | static var previews: some View { 41 | PlayPauseView(play: false, width: 300) 42 | .frame(width: 400, height: 400) 43 | } 44 | } 45 | 46 | // swiftlint:disable identifier_name 47 | struct Tetragon: Shape { 48 | typealias AnimatableData = CGFloat 49 | 50 | var p0, p1, p2, p3: CGPoint 51 | var animateOn: WritableKeyPath 52 | 53 | internal init( 54 | p0: CGPoint = .init(x: 0, y: 1), 55 | p1: CGPoint = .init(x: 0, y: 0), 56 | p2: CGPoint = .init(x: 1, y: 0), 57 | p3: CGPoint = .init(x: 1, y: 1), 58 | animateOn: WritableKeyPath) { 59 | self.p0 = p0 60 | self.p1 = p1 61 | self.p2 = p2 62 | self.p3 = p3 63 | self.animateOn = animateOn 64 | } 65 | 66 | var animatableData: CGFloat { 67 | get { self[keyPath: animateOn] } 68 | set { self[keyPath: animateOn] = newValue } 69 | } 70 | 71 | func path(in rect: CGRect) -> Path { 72 | Path { path in 73 | path.move(to: CGPoint(x: p0.x * rect.size.width, y: p0.y * rect.size.height)) 74 | path.addLine(to: CGPoint(x: p1.x * rect.size.width, y: p1.y * rect.size.height)) 75 | path.addLine(to: CGPoint(x: p2.x * rect.size.width, y: p2.y * rect.size.height)) 76 | path.addLine(to: CGPoint(x: p3.x * rect.size.width, y: p3.y * rect.size.height)) 77 | path.addLine(to: CGPoint(x: p0.x * rect.size.width, y: p0.y * rect.size.height)) 78 | } 79 | } 80 | } 81 | // swiftlint:enable identifier_name 82 | -------------------------------------------------------------------------------- /SimpleMath/UI/Main/ProgressView.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import SwiftUI 6 | 7 | struct ProgressView: View { 8 | private var play: Bool 9 | private let width: CGFloat 10 | private let strokeWidth: CGFloat 11 | private let progress: Double 12 | private let circleSize: CGFloat 13 | private let pulse: Bool 14 | private let action: () -> Void 15 | @State private var pulseFactor: CGFloat = 0 16 | 17 | init(width: CGFloat, progress: Double, play: Bool, pulse: Bool, action: @escaping () -> Void) { 18 | self.width = width 19 | self.progress = progress 20 | self.play = play 21 | self.pulse = pulse 22 | self.action = action 23 | let strokeScale: CGFloat = 0.13 24 | circleSize = width / ( 1 + strokeScale) 25 | strokeWidth = circleSize * strokeScale 26 | } 27 | 28 | var body: some View { 29 | ZStack { 30 | Circle() 31 | .stroke(lineWidth: strokeWidth) 32 | .frame(width: circleSize, height: circleSize) 33 | .foregroundColor(.progressIncomplete) 34 | Circle() 35 | .trim(from: CGFloat(1 - progress), to: 1) 36 | .stroke(lineWidth: strokeWidth) 37 | .rotationEffect(.degrees(90)) 38 | .rotation3DEffect(.degrees(180), axis: (x: 1, y: 0, z: 0)) 39 | 40 | .frame(width: circleSize, height: circleSize) 41 | .foregroundColor(Color.progressComplete.opacity(0.7)) 42 | PlayPauseView(play: play, width: width * 0.5) 43 | .foregroundColor(.playPause) 44 | } 45 | 46 | .frame(width: width, height: width) 47 | .anchorPreference(key: BoundsAnchorKey.self, value: .bounds, transform: { $0 }) 48 | .backgroundPreferenceValue(BoundsAnchorKey.self) { anchor in 49 | if self.pulse { 50 | GeometryReader { proxy in 51 | Circle() 52 | .stroke(lineWidth: 2) 53 | .foregroundColor(Color.primaryText.opacity(Double(1 - self.pulseFactor) * 0.4)) 54 | .scaleEffect(0.9) 55 | .scaleEffect(self.pulseFactor + 1) 56 | .animation(Animation.linear(duration: 2).repeatForever(autoreverses: false)) 57 | .offset(x: proxy[anchor!].minX, y: proxy[anchor!].minY) 58 | } 59 | } 60 | } 61 | .onAppear { self.pulseFactor = 1 } 62 | .animation(.spring()) 63 | .onTapGesture { 64 | withAnimation { 65 | self.action() 66 | } 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /SimpleMath/UI/Results/GreatSuccessOverlayView.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import SwiftUI 6 | 7 | struct GreatSuccessOverlayView: View { 8 | @Environment(\.horizontalSizeClass) private var hSizeClass 9 | @State private var scale: CGFloat = 1.0 10 | @State private var angle: Double = 0 11 | private var axisOffset: CGFloat { hSizeClass.isRegular ? 240 : 120 } 12 | private var diagonalOffset: CGFloat { hSizeClass.isRegular ? 180 : 90 } 13 | 14 | var body: some View { 15 | ZStack { 16 | Text("🤩") 17 | .font(Font.system(size: self.hSizeClass.isRegular ? 260 : 130, weight: .bold, design: .default)) 18 | StarView(xOffset: -axisOffset, yOffset: 0, scale: scale, angle: angle) 19 | StarView(xOffset: -diagonalOffset, yOffset: diagonalOffset, scale: scale, angle: angle) 20 | StarView(xOffset: 0, yOffset: axisOffset, scale: scale, angle: angle) 21 | StarView(xOffset: diagonalOffset, yOffset: diagonalOffset, scale: scale, angle: angle) 22 | StarView(xOffset: axisOffset, yOffset: 0, scale: scale, angle: angle) 23 | StarView(xOffset: diagonalOffset, yOffset: -diagonalOffset, scale: scale, angle: angle) 24 | StarView(xOffset: 0, yOffset: -axisOffset, scale: scale, angle: angle) 25 | StarView(xOffset: -diagonalOffset, yOffset: -diagonalOffset, scale: scale, angle: angle) 26 | } 27 | .onAppear { 28 | withAnimation(Animation.easeInOut(duration: 2.0).repeatForever(autoreverses: true)) { 29 | self.scale = self.scale == 1 ? 0.7 : 1.0 30 | self.angle = self.angle == 360.0 ? 0 : 360 31 | } 32 | } 33 | } 34 | } 35 | 36 | private struct StarView: View { 37 | @Environment(\.horizontalSizeClass) private var hSizeClass 38 | let xOffset: CGFloat 39 | let yOffset: CGFloat 40 | let scale: CGFloat 41 | let angle: Double 42 | 43 | var body: some View { 44 | Image(withSymbol: .star) 45 | .font(Font.system(size: hSizeClass.isRegular ? 100 : 50, weight: .bold, design: .default)) 46 | .foregroundColor(.yellowStar) 47 | .scaleEffect(scale) 48 | .rotationEffect(.degrees(angle)) 49 | .offset(x: xOffset, y: yOffset) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /SimpleMath/UI/Results/ResetButton.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import SwiftUI 6 | 7 | struct ResetButton: View { 8 | @Environment(\.horizontalSizeClass) private var hSizeClass 9 | let action: () -> Void 10 | 11 | var body: some View { 12 | Button(action: action) { 13 | Image(withSymbol: .reset) 14 | .font(Font.system(size: hSizeClass.isRegular ? 80 : 40, weight: .bold, design: .monospaced)) 15 | .foregroundColor(.confirm) 16 | } 17 | .padding(.trailing, 20) 18 | .padding(.top, 34) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /SimpleMath/UI/Results/ResultRowView.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import SwiftUI 6 | 7 | struct ResultRowView: View { 8 | let equation: Equation 9 | 10 | var body: some View { 11 | HStack { 12 | Text(equation.question) 13 | .foregroundColor(.primaryText) 14 | Text(equation.currentAnswerText) 15 | .foregroundColor(equation.correctlyAnswered ? .correctAnswer : .incorrectAnswer) 16 | .strikethrough(!equation.correctlyAnswered, color: .incorrectAnswer) 17 | if !equation.correctlyAnswered { 18 | Text(equation.correctAnswer.description) 19 | .foregroundColor(.correctAnswer) 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /SimpleMath/UI/Results/ResultsHeaderView.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import SwiftUI 6 | 7 | struct ResultsHeaderView: View { 8 | let correctAnswers: Int 9 | let wrongAnswers: Int 10 | 11 | var body: some View { 12 | HStack { 13 | Text(correctAnswers.description) 14 | Image(withSymbol: .star) 15 | .font(Font.system(size: 30, weight: .bold, design: .monospaced)) 16 | if wrongAnswers > 0 { 17 | Text(wrongAnswers.description) 18 | .padding(.leading, 10) 19 | .foregroundColor(.incorrectAnswer) 20 | Text("😞") 21 | } 22 | Spacer() 23 | } 24 | .font(Font.system(size: 34, weight: .bold, design: .monospaced)) 25 | .foregroundColor(.correctAnswer) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /SimpleMath/UI/Results/ResultsView.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import CornerStacks 6 | import SwiftUI 7 | 8 | struct ResultsView: View { 9 | @Environment(\.horizontalSizeClass) private var hSizeClass 10 | @EnvironmentObject private var viewModel: SimpleMathViewModel 11 | @EnvironmentObject private var presentation: ModalPesentation 12 | 13 | var body: some View { 14 | ZStack { 15 | Color.background 16 | .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) 17 | .edgesIgnoringSafeArea(.all) 18 | VStack { 19 | ResultsHeaderView(correctAnswers: viewModel.correctAnswers, wrongAnswers: viewModel.wrongAnswers) 20 | ScrollView { 21 | HStack(spacing: 0) { 22 | VStack(alignment: .leading) { 23 | ForEach(self.viewModel.equations.indices, id: \.self) { index in 24 | ResultRowView(equation: self.viewModel.equations[index]) 25 | } 26 | } 27 | Spacer() 28 | } 29 | .font(Font.system(size: self.hSizeClass.isRegular ? 34 : 28, weight: .bold, design: .monospaced)) 30 | } 31 | Spacer() 32 | } 33 | .padding(.all, 30) 34 | TopTrailingHStack { 35 | ResetButton(action: { 36 | self.presentation.willDismiss = true 37 | self.viewModel.reset() 38 | }) 39 | } 40 | if viewModel.greatSuccess { 41 | GreatSuccessOverlayView() 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /SimpleMath/UI/Settings/DigitRangeSettingSectionView.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import SwiftUI 6 | 7 | struct DigitRangeSettingSectionView: View { 8 | @EnvironmentObject private var viewModel: SettingsViewModel 9 | @Environment(\.horizontalSizeClass) private var hSizeClass 10 | 11 | var body: some View { 12 | VStack(spacing: 8) { 13 | HStack { 14 | TextField( 15 | "", 16 | text: Binding( 17 | get: { self.viewModel.minRangeText }, 18 | set: viewModel.updateMinRange(text:) 19 | ) 20 | ) 21 | .modifier(DigitTextField(isValid: viewModel.isRangeValid)) 22 | Spacer() 23 | Image(withSymbol: .leftArrow) 24 | .font(.system(size: hSizeClass.isRegular ? 26 : 20, weight: .bold, design: .monospaced)) 25 | Spacer() 26 | Text("X") 27 | .font(.system(size: hSizeClass.isRegular ? 28 : 20, weight: .heavy, design: .monospaced)) 28 | Spacer() 29 | Image(withSymbol: .rightArrow) 30 | .font(.system(size: hSizeClass.isRegular ? 26 : 20, weight: .bold, design: .monospaced)) 31 | Spacer() 32 | TextField( 33 | "", text: Binding( 34 | get: { self.viewModel.maxRangeText }, 35 | set: { self.viewModel.updateMaxRange(text: $0)} 36 | ) 37 | ) 38 | .modifier(DigitTextField(isValid: viewModel.isRangeValid)) 39 | } 40 | 41 | HStack { 42 | Text("X + X = ") 43 | Text(" ") 44 | .frame(width: hSizeClass.isRegular ? 54 : 40) 45 | .background(Color.unanswered) 46 | .clipShape(Capsule()) 47 | 48 | } 49 | .font(.system(size: hSizeClass.isRegular ? 24 : 16, weight: .bold, design: .monospaced)) 50 | .opacity(0.6) 51 | } 52 | .animation(nil) 53 | .padding() 54 | .modifier(SettingCell(maxWidth: .infinity)) 55 | } 56 | } 57 | 58 | private struct DigitTextField: ViewModifier { 59 | @Environment(\.horizontalSizeClass) private var hSizeClass 60 | var isValid: Bool 61 | 62 | func body(content: Content) -> some View { 63 | content 64 | .multilineTextAlignment(.center) 65 | .keyboardType(.numberPad) 66 | .font(.system(size: hSizeClass.isRegular ? 40 : 30)) 67 | .frame(width: hSizeClass.isRegular ? 80 : 60, alignment: .center) 68 | .background( 69 | RoundedRectangle(cornerRadius: 8, style: .continuous) 70 | .stroke(lineWidth: 2) 71 | .foregroundColor(isValid ? .settingTextFieldBorder : .settingTextFieldBorderInvalid) 72 | ) 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /SimpleMath/UI/Settings/EquationTypeSettingView.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import SwiftUI 6 | 7 | struct EquationTypeSettingView: View { 8 | @EnvironmentObject private var viewModel: SettingsViewModel 9 | 10 | var body: some View { 11 | VStack(alignment: .center, spacing: 16) { 12 | EquationTypeRow( 13 | description: "X + Y = ", 14 | isEnabled: viewModel.additionEnabled, 15 | enable: { self.viewModel.enableEquation(type: .addition) }, 16 | disable: { self.viewModel.disableEquation(type: .addition)} 17 | ) 18 | EquationTypeRow( 19 | description: "X - Y = ", 20 | isEnabled: viewModel.subtractonEnabled, 21 | enable: { self.viewModel.enableEquation(type: .subtraction) }, 22 | disable: { self.viewModel.disableEquation(type: .subtraction)} 23 | ) 24 | EquationTypeRow( 25 | description: "X × Y = ", 26 | isEnabled: viewModel.multiplicationEnabled, 27 | enable: { self.viewModel.enableEquation(type: .multiplication) }, 28 | disable: { self.viewModel.disableEquation(type: .multiplication)} 29 | ) 30 | EquationTypeRow( 31 | description: "X ÷ Y = ", 32 | isEnabled: viewModel.divisionEnabled, 33 | enable: { self.viewModel.enableEquation(type: .division) }, 34 | disable: { self.viewModel.disableEquation(type: .division)} 35 | ) 36 | } 37 | .padding() 38 | .modifier(SettingCell(maxWidth: .infinity)) 39 | } 40 | } 41 | 42 | private struct EquationTypeRow: View { 43 | @Environment(\.horizontalSizeClass) private var hSizeClass 44 | var description: String 45 | var isEnabled: Bool 46 | var enable: () -> Void 47 | var disable: () -> Void 48 | 49 | var body: some View { 50 | HStack { 51 | Image(withSymbol: isEnabled ? .checkmarkFilled : .checkmark) 52 | .foregroundColor(isEnabled ? .settingEquationEnabled : Color.settingEquationDisabled.opacity(0.5)) 53 | .font(.system(size: hSizeClass.isRegular ? 26 : 20)) 54 | Text(description) 55 | .font(.system(size: hSizeClass.isRegular ? 28 : 20, weight: .regular, design: .monospaced)) 56 | .padding(.leading, 22) 57 | Text(" ") 58 | .font(.system(size: hSizeClass.isRegular ? 28 : 20, weight: .regular, design: .monospaced)) 59 | .frame(width: hSizeClass.isRegular ? 56 : 46) 60 | .background(Color.unanswered) 61 | .clipShape(Capsule()) 62 | } 63 | .onTapGesture { 64 | self.isEnabled ? self.disable() : self.enable() 65 | } 66 | .frame(maxWidth: .infinity) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /SimpleMath/UI/Settings/MiniDeviceView.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import SwiftUI 6 | 7 | struct Container: View { 8 | var body: some View { 9 | ZStack { 10 | MiniDeviceView(width: 100, animate: true) 11 | } 12 | } 13 | } 14 | 15 | struct MiniDeviceView: View { 16 | private let timer = Timer.publish(every: 1.6, on: .main, in: .common).autoconnect() 17 | @State private var isDrifted = false 18 | private let height: CGFloat 19 | private var inputMockupColor = Color.miniDeviceInputMockupGray 20 | private var correctAnswersMockupColor = Color.miniDeviceInputMockupGreen 21 | private var equationRowColor = Color.primaryText 22 | var width: CGFloat 23 | var animate: Bool 24 | 25 | init(width: CGFloat, animate: Bool) { 26 | self.width = width 27 | self.animate = animate 28 | height = width * 2 29 | } 30 | var body: some View { 31 | ZStack { 32 | IPhoneView(width: width, height: height, screenColor: Color.background) 33 | equationsMockup() 34 | .animation(.easeInOut(duration: 1.5)) 35 | .onReceive(timer) { _ in 36 | guard self.animate else { return } 37 | self.isDrifted.toggle() 38 | } 39 | inputMockup() 40 | } 41 | .drawingGroup() 42 | .frame(width: width, height: height) 43 | } 44 | 45 | private func square(size: CGSize, color: Color) -> some View { 46 | Rectangle() 47 | .fill(color) 48 | .frame(width: size.width, height: size.height) 49 | } 50 | 51 | private func squareRow(squareSize: CGSize, count: Int, color: Color) -> some View { 52 | HStack(spacing: width / 15 ) { 53 | ForEach(0.. some View { 60 | let squareSize = CGSize(width: width * 0.08, height: width * 0.08) 61 | return Group { 62 | 63 | ForEach(0..<3) { index in 64 | self.squareRow(squareSize: squareSize, count: 3, color: self.inputMockupColor) 65 | .offset(x: 0, y: self.height * (0.12 + CGFloat(index) * 0.07)) 66 | } 67 | 68 | squareRow(squareSize: squareSize, count: 1, color: inputMockupColor) 69 | .offset(x: 0, y: height * 0.33) 70 | square(size: squareSize, color: inputMockupColor) 71 | .offset(x: -width * 0.33, y: height * 0.19) 72 | square(size: squareSize, color: inputMockupColor) 73 | .offset(x: width * 0.33, y: height * 0.19) 74 | square(size: squareSize, color: correctAnswersMockupColor) 75 | .offset(x: width * 0.33, y: -height * 0.31) 76 | square(size: squareSize, color: inputMockupColor) 77 | .offset(x: -width * 0.33, y: -height * 0.31) 78 | } 79 | } 80 | 81 | // disabling linter because this will be refactored 82 | // swiftlint:disable:next function_body_length 83 | private func equationsMockup() -> some View { 84 | Group { 85 | Rectangle() 86 | .fill(equationRowColor) 87 | .frame(width: width * 0.4, height: width * 0.1) 88 | .offset(x: isDrifted ? -width * 0.15 : -width * 0.1, y: height * 0.04) 89 | 90 | Rectangle() 91 | .fill(Color.unanswered) 92 | .frame(width: width * 0.15, height: width * 0.1) 93 | .offset(x: isDrifted ? width * 0.17 : width * 0.22, y: height * 0.04) 94 | 95 | Rectangle() 96 | .fill(equationRowColor) 97 | .frame(width: width * 0.4, height: width * 0.1) 98 | .offset(x: isDrifted ? -width * 0.05 : -width * 0.1, y: -height * 0.03) 99 | 100 | Rectangle() 101 | .fill(Color.unanswered) 102 | .frame(width: width * 0.15, height: width * 0.1) 103 | .offset(x: isDrifted ? width * 0.27 : width * 0.22, y: -height * 0.03) 104 | 105 | Rectangle() 106 | .fill(equationRowColor) 107 | .frame(width: width * 0.4, height: width * 0.1) 108 | .offset(x: isDrifted ? -width * 0.15 : -width * 0.1, y: -height * 0.10) 109 | 110 | Rectangle() 111 | .fill(Color.currentAnswer) 112 | .frame(width: width * 0.15, height: width * 0.1) 113 | .offset(x: isDrifted ? width * 0.17 : width * 0.22, y: -height * 0.10) 114 | 115 | Rectangle() 116 | .fill(equationRowColor) 117 | .frame(width: width * 0.4, height: width * 0.1) 118 | .offset(x: isDrifted ? -width * 0.05 : -width * 0.1, y: -height * 0.17) 119 | 120 | Rectangle() 121 | .fill(Color.correctAnswer) 122 | .frame(width: width * 0.15, height: width * 0.1) 123 | .offset(x: isDrifted ? width * 0.27 : width * 0.22, y: -height * 0.17) 124 | 125 | Rectangle() 126 | .fill(equationRowColor) 127 | .frame(width: width * 0.4, height: width * 0.1) 128 | .offset(x: isDrifted ? -width * 0.15 : -width * 0.1, y: -height * 0.24) 129 | 130 | Rectangle() 131 | .fill(Color.correctAnswer) 132 | .frame(width: width * 0.15, height: width * 0.1) 133 | .offset(x: isDrifted ? width * 0.17 : width * 0.22, y: -height * 0.24) 134 | } 135 | } 136 | } 137 | 138 | private struct IPhoneView: View { 139 | private var hardwareStrokeColor = Color.miniDeviceHardwareStroke 140 | let width: CGFloat 141 | let height: CGFloat 142 | var screenColor: Color 143 | 144 | init(width: CGFloat, height: CGFloat, screenColor: Color) { 145 | self.width = width 146 | self.height = height 147 | self.screenColor = screenColor 148 | } 149 | 150 | var body: some View { 151 | ZStack { 152 | // bazel 153 | RoundedRectangle(cornerRadius: width / 6.6, style: .continuous) 154 | .fill(Color.miniDeviceFrame) 155 | // screen 156 | Rectangle() 157 | .fill(screenColor) 158 | .frame(width: width * 0.83, height: height * 0.74) 159 | // home button 160 | Circle() 161 | .stroke(lineWidth: width / 100) 162 | .foregroundColor(hardwareStrokeColor) 163 | .frame(width: width * 0.15, height: width * 0.15) 164 | .offset(x: 0, y: height * 0.43) 165 | // camera 166 | Circle() 167 | .stroke(lineWidth: width / 200) 168 | .foregroundColor(hardwareStrokeColor) 169 | .frame(width: width * 0.045, height: width * 0.045) 170 | .offset(x: 0, y: -height * 0.45) 171 | // speaker 172 | RoundedRectangle(cornerRadius: width * 0.015, style: .continuous) 173 | .stroke(lineWidth: width / 200) 174 | .foregroundColor(hardwareStrokeColor) 175 | .frame(width: width * 0.16, height: height * 0.014) 176 | .offset(x: 0, y: -height * 0.405) 177 | } 178 | .frame(width: width, height: height) 179 | } 180 | } 181 | 182 | struct SwiftUIView_Previews: PreviewProvider { 183 | static var previews: some View { 184 | Container() 185 | .previewDevice(PreviewDevice(rawValue: "iPhone 8")) 186 | .previewDisplayName("iPhone 8") 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /SimpleMath/UI/Settings/NumberOfEquationsSettingView.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import SwiftUI 6 | 7 | struct NumberOfEquationsSettingView: View { 8 | @EnvironmentObject private var viewModel: SettingsViewModel 9 | @Environment(\.horizontalSizeClass) private var hSizeClass 10 | var isVisible: Bool 11 | 12 | var body: some View { 13 | HStack { 14 | VStack(spacing: 8) { 15 | Text(viewModel.numberOfEquations.description) 16 | .font(.system(size: hSizeClass.isRegular ? 54 : 40, weight: .regular)) 17 | .animation(nil) 18 | 19 | HStack(spacing: 18) { 20 | Button(action: viewModel.decreaseNumberOfEquations) { 21 | Image(withSymbol: .minus) 22 | .frame(width: 60, height: 44) 23 | .background(Color.settingStepperBackground) 24 | .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) 25 | } 26 | 27 | Button(action: viewModel.increaseNumberOfEquations) { 28 | Image(withSymbol: .plus) 29 | .frame(width: 60, height: 44) 30 | .background(Color.settingStepperBackground) 31 | .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) 32 | } 33 | } 34 | .font(.system(size: 20)) 35 | } 36 | .frame(maxWidth: .infinity) 37 | 38 | MiniDeviceView(width: hSizeClass.isRegular ? 76 : 64, animate: isVisible) 39 | } 40 | .padding(.vertical, 10) 41 | .padding(.horizontal) 42 | .modifier(SettingCell(maxWidth: .infinity)) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /SimpleMath/UI/Settings/SettingCell.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import SwiftUI 6 | 7 | struct SettingCell: ViewModifier { 8 | var maxWidth: CGFloat 9 | 10 | func body(content: Content) -> some View { 11 | content 12 | .frame(maxWidth: maxWidth) 13 | .background(Color.settingCellBackgroundBase.opacity(0.05)) 14 | .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /SimpleMath/UI/Settings/SettingsPanelView.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import SwiftUI 6 | 7 | struct SettingsPanelView: View { 8 | @EnvironmentObject private var viewModel: SettingsViewModel 9 | @Environment(\.horizontalSizeClass) private var hSizeClass 10 | private let cellSpacing: CGFloat = 12 11 | private var width: CGFloat { hSizeClass.isRegular ? 740 : CGFloat.screenWidth - 48 } 12 | private var height: CGFloat? { hSizeClass.isRegular ? nil : 380 + (CGFloat.screenHeight > 568 ? 104 : 0)} 13 | @Binding var show: Bool 14 | 15 | var body: some View { 16 | ZStack { 17 | Color.black 18 | .opacity(show ? 0.0001 : 0) // invisible to the eye but enough to make it hittable 19 | .edgesIgnoringSafeArea(.all) 20 | .onTapGesture(perform: { 21 | guard self.viewModel.isRangeValid else { return } 22 | withAnimation { 23 | self.viewModel.commitChanges() 24 | self.show = false 25 | } 26 | }) 27 | 28 | if hSizeClass.isRegular { 29 | HStack(alignment: .top, spacing: cellSpacing) { 30 | VStack(spacing: cellSpacing) { 31 | DigitRangeSettingSectionView() 32 | NumberOfEquationsSettingView(isVisible: show) 33 | } 34 | VStack(spacing: cellSpacing) { 35 | EquationTypeSettingView() 36 | SoundToggleSettingView() 37 | } 38 | } 39 | .padding() 40 | .animation(nil) 41 | .modifier(SettingPanel(isVisible: show, maxWidth: width, maxHeight: height)) 42 | } else { 43 | ScrollView { 44 | VStack(spacing: cellSpacing) { 45 | DigitRangeSettingSectionView() 46 | NumberOfEquationsSettingView(isVisible: show) 47 | EquationTypeSettingView() 48 | SoundToggleSettingView() 49 | } 50 | .padding() 51 | .animation(nil) 52 | } 53 | .modifier(SettingPanel(isVisible: show, maxWidth: width, maxHeight: height)) 54 | } 55 | } 56 | .animation(Animation.easeInOut(duration: 0.3).delay(0.12)) 57 | } 58 | } 59 | 60 | private struct SettingPanel: ViewModifier { 61 | var isVisible: Bool 62 | var maxWidth: CGFloat 63 | var maxHeight: CGFloat? 64 | 65 | func body(content: Content) -> some View { 66 | content 67 | .foregroundColor(.primaryText) 68 | .frame(maxWidth: maxWidth, maxHeight: maxHeight) 69 | .background(Color.settingPanelBackgroundBase.opacity(0.6)) 70 | .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) 71 | .opacity(isVisible ? 1: 0) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /SimpleMath/UI/Settings/SoundToggleSettingView.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import SwiftUI 6 | 7 | struct SoundToggleSettingView: View { 8 | @EnvironmentObject private var viewModel: SettingsViewModel 9 | @Environment(\.horizontalSizeClass) private var hSizeClass 10 | 11 | var body: some View { 12 | HStack { 13 | Image(withSymbol: viewModel.areSoundsEnabled ? .soundsEnabled : .soundsDisabled) 14 | .font(.system(size: hSizeClass.isRegular ? 26 : 20)) 15 | Toggle( 16 | isOn: Binding( 17 | get: { self.viewModel.areSoundsEnabled }, 18 | set: { self.viewModel.updateSoundsEnabled(to: $0) } 19 | ), 20 | label: { EmptyView() } 21 | ) 22 | } 23 | .padding() 24 | .modifier(SettingCell(maxWidth: .infinity)) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /SimpleMathTests/.swiftlint.yml: -------------------------------------------------------------------------------- 1 | # There are some rules that we would like to disable for UNIT tests 2 | opt_in_rules: 3 | - line_length 4 | line_length: 140 5 | disabled_rules: 6 | - function_body_length # we do not care about the function bodies spanning across too many lines 7 | - type_body_length # we do not care about the number of lines in the body 8 | - file_length # we do not care about the file length 9 | - type_name # we allow long type names in UNIT tests as they are more descriptive 10 | - weak_delegate # we allow weak delegate in UNIT tests 11 | - identifier_name # we allow longer identifier names in UNIT tests 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /SimpleMathTests/App/Equations/EquationTests.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | @testable import SimpleMath 6 | import XCTest 7 | 8 | class EquationTests: XCTestCase { 9 | 10 | func testInit_hasExpectedDefaultValues() { 11 | let equation = Equation(left: 4, right: 2, operator: .testOperator, answerDigitLimit: 2) 12 | XCTAssertEqual(equation.currentAnswerText, "", "by default currentAnswerText is empty string") 13 | XCTAssertFalse(equation.finishedAnswering, "by default finishedAnswering is false") 14 | XCTAssertFalse(equation.hasValidAnswer, "by default hasValidAnswer is false") 15 | XCTAssertFalse(equation.correctlyAnswered, "by default correctlyAnswered is false") 16 | } 17 | 18 | func testQuestion_isCorrectlyFormatted() { 19 | let equation = Equation(left: 4, right: 2, operator: .testOperator, answerDigitLimit: 2) 20 | XCTAssertEqual(equation.question, "4 mod 2 = ", "expected format is `operand operator operant = `") 21 | } 22 | 23 | func testCorrectAnswer_executesOperatorUsingOperandsCorrectlyToProduceAnswer() { 24 | let equation = Equation(left: 4, right: 2, operator: .testOperator, answerDigitLimit: 2) 25 | XCTAssertEqual(equation.correctAnswer, 0, "Expected 4 % 2 == 0") 26 | } 27 | 28 | func testAppendDigit_updatesCurrentAnswerTextAndHasValidAnswerAndCorrectlyAnswered() { 29 | var equation = Equation(left: 4, right: 2, operator: .testOperator, answerDigitLimit: 2) 30 | equation.append(digit: 0) 31 | XCTAssertEqual(equation.currentAnswerText, "0", "currentAnswerText should be string representaiton of `0`") 32 | XCTAssertTrue(equation.hasValidAnswer, "If any digit has been provided than it is a `validAnswer`") 33 | XCTAssertTrue(equation.correctlyAnswered, "if the provided digits evaluate to correct answer than it should be `true`") 34 | } 35 | 36 | func testAppendDigit_correctlyAppendsTheLastDigitAndCanNotPassTheAnswerDigitLimit() { 37 | var equation = Equation(left: 4, right: 2, operator: .testOperator, answerDigitLimit: 2) 38 | equation.append(digit: 1) 39 | equation.append(digit: 1) 40 | equation.append(digit: 1) // nothing should happen 41 | XCTAssertEqual(equation.currentAnswerText, "11", "expected only 2 `1s` to be appended giving `11`, third one surpassed maximum") 42 | } 43 | 44 | func testErase_correctlyRemovesDigitsAndInvalidatesHasValidAnswerAndCorrectlyAnswered() { 45 | var equation = Equation(left: 4, right: 2, operator: .testOperator, answerDigitLimit: 2) 46 | equation.append(digit: 0) // this makes the equation have a valid and correct answer 47 | equation.erase() 48 | XCTAssertEqual(equation.currentAnswerText, "", "after erasing the last digit, currentAnswerText should be empty string") 49 | XCTAssertFalse(equation.hasValidAnswer, "after erasing the last digit, hasValidAnswer should be false") 50 | XCTAssertFalse(equation.correctlyAnswered, "after erasing last digit there is no answer to compare against correct one, so false") 51 | } 52 | 53 | func testEvaluate_ifAnyAnswerWasProvided_setsFinishedAnsweringTrue() { 54 | var equation = Equation(left: 4, right: 2, operator: .testOperator, answerDigitLimit: 2) 55 | equation.append(digit: 0) 56 | equation.evaluate() 57 | XCTAssertTrue(equation.finishedAnswering, "when evaluate is called with valid answer, finishedAnswering is set to true") 58 | } 59 | 60 | func testEvaluate_ifNoAnswerWasProvided_DoesNothing() { 61 | var equation = Equation(left: 4, right: 2, operator: .testOperator, answerDigitLimit: 2) 62 | equation.evaluate() 63 | XCTAssertFalse(equation.finishedAnswering, "when evaluate is called with no valid answer, finishedAnswering is still false") 64 | } 65 | 66 | } 67 | 68 | extension Operator { 69 | static let testOperator = Operator(equationType: .addition, symbol: "mod", function: %) 70 | } 71 | -------------------------------------------------------------------------------- /SimpleMathTests/App/Equations/EquationsFactoryImpTests.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | @testable import SimpleMath 6 | import XCTest 7 | 8 | class EquationsFactoryImpTests: XCTestCase { 9 | 10 | func testMakeEquations_resultingMaxOperandDigitsIsTheSameCountAsInMaximumDigitNumberOfDigitsInEquationSettings() { 11 | let factory = EquationsFactoryImp() 12 | let equationSettings = EquationSettings.standard.set(\.maximumDigit, to: 133) 13 | let result = factory.makeEquations(usingSettings: equationSettings) 14 | XCTAssertEqual( 15 | result.maxOperandDigits, 16 | 3, 17 | "Expected maxOperandDigits should be the digits count of the setting's maximumDigit, in this case 3 digits" 18 | ) 19 | } 20 | 21 | func testMakeEquations_ifMultiplicationIsOneOfTheEquationTypes_resultingMaxAnswerDigitsIsCorrectlyCalculated() { 22 | let factory = EquationsFactoryImp() 23 | var equationSettings = EquationSettings.standard 24 | .set(\.maximumDigit, to: 3) 25 | .set(\.equationTypes, to: [.addition, .subtraction, .multiplication, .division]) 26 | var result = factory.makeEquations(usingSettings: equationSettings) 27 | XCTAssertEqual( 28 | result.maxAnswerDigits, 29 | 1, 30 | "Expected maxAnswerDigits digits count in a resulting multiplication of the maximumDigit, 3 * 3 = 9, 9 has 1 digit" 31 | ) 32 | 33 | equationSettings = EquationSettings.standard 34 | .set(\.maximumDigit, to: 9) 35 | .set(\.equationTypes, to: [.addition, .subtraction, .multiplication, .division]) 36 | result = factory.makeEquations(usingSettings: equationSettings) 37 | XCTAssertEqual( 38 | result.maxAnswerDigits, 39 | 2, 40 | "Expected maxAnswerDigits digits count in a resulting multiplication of the maximumDigit, 9 * 9 = 81, 81 has 2 digits" 41 | ) 42 | 43 | equationSettings = EquationSettings.standard 44 | .set(\.maximumDigit, to: 20) 45 | .set(\.equationTypes, to: [.addition, .subtraction, .multiplication, .division]) 46 | result = factory.makeEquations(usingSettings: equationSettings) 47 | XCTAssertEqual( 48 | result.maxAnswerDigits, 49 | 3, 50 | "Expected maxAnswerDigits digits count in a resulting multiplication of the maximumDigit, 20 * 20 = 200, 200 has 3 digits" 51 | ) 52 | 53 | equationSettings = EquationSettings.standard 54 | .set(\.maximumDigit, to: 50) 55 | .set(\.equationTypes, to: [.addition, .subtraction, .multiplication, .division]) 56 | result = factory.makeEquations(usingSettings: equationSettings) 57 | XCTAssertEqual( 58 | result.maxAnswerDigits, 59 | 4, 60 | "Expected maxAnswerDigits digits count in a resulting multiplication of the maximumDigit, 50 * 50 = 2500, 2500 has 4 digits" 61 | ) 62 | } 63 | 64 | func testMakeEquations_ifAdditionIsOneOfTheEquationTypesButNotMultiplication_resultingMaxAnswerDigitsIsCorrectlyCalculated() { 65 | let factory = EquationsFactoryImp() 66 | var equationSettings = EquationSettings.standard 67 | .set(\.maximumDigit, to: 4) 68 | .set(\.equationTypes, to: [.addition, .subtraction, .division]) 69 | var result = factory.makeEquations(usingSettings: equationSettings) 70 | XCTAssertEqual( 71 | result.maxAnswerDigits, 72 | 1, 73 | "Expected maxAnswerDigits digits count in a resulting addition of the maximumDigit, 4 + 4 = 8, 8 has 1 digit" 74 | ) 75 | 76 | equationSettings = EquationSettings.standard 77 | .set(\.maximumDigit, to: 9) 78 | .set(\.equationTypes, to: [.addition, .subtraction, .division]) 79 | result = factory.makeEquations(usingSettings: equationSettings) 80 | XCTAssertEqual( 81 | result.maxAnswerDigits, 82 | 2, 83 | "Expected maxAnswerDigits digits count in a resulting addition of the maximumDigit, 9 + 9 = 18, 18 has 2 digits" 84 | ) 85 | 86 | equationSettings = EquationSettings.standard 87 | .set(\.maximumDigit, to: 50) 88 | .set(\.equationTypes, to: [.addition, .subtraction, .division]) 89 | result = factory.makeEquations(usingSettings: equationSettings) 90 | XCTAssertEqual( 91 | result.maxAnswerDigits, 92 | 3, 93 | "Expected maxAnswerDigits digits count in a resulting addition of the maximumDigit, 50 + 50 = 100, 100 has 3 digits" 94 | ) 95 | } 96 | 97 | func testMakeEquations_ifAdditionOrMultiplicationIsNotOneOfTheEquationTypes_resultingMaxAnswerDigitsIsCorrectlyCalculated() { 98 | let factory = EquationsFactoryImp() 99 | var equationSettings = EquationSettings.standard 100 | .set(\.maximumDigit, to: 9) 101 | .set(\.equationTypes, to: [.subtraction, .division]) 102 | var result = factory.makeEquations(usingSettings: equationSettings) 103 | XCTAssertEqual( 104 | result.maxAnswerDigits, 105 | 1, 106 | "Expected maxAnswerDigits digits to be the same as the maximumDigit number of digits" 107 | ) 108 | 109 | equationSettings = EquationSettings.standard 110 | .set(\.maximumDigit, to: 10) 111 | .set(\.equationTypes, to: [.subtraction, .division]) 112 | result = factory.makeEquations(usingSettings: equationSettings) 113 | XCTAssertEqual( 114 | result.maxAnswerDigits, 115 | 2, 116 | "Expected maxAnswerDigits digits to be the same as the maximumDigit number of digits" 117 | ) 118 | 119 | equationSettings = EquationSettings.standard 120 | .set(\.maximumDigit, to: 99) 121 | .set(\.equationTypes, to: [.subtraction, .division]) 122 | result = factory.makeEquations(usingSettings: equationSettings) 123 | XCTAssertEqual( 124 | result.maxAnswerDigits, 125 | 2, 126 | "Expected maxAnswerDigits digits to be the same as the maximumDigit number of digits" 127 | ) 128 | } 129 | 130 | func testMakeEquations_producesNumberOfEquationsDefinedInEquationSettings() { 131 | let factory = EquationsFactoryImp() 132 | let equationSettings = EquationSettings.standard.set(\.equationsCount, to: 25) 133 | let result = factory.makeEquations(usingSettings: equationSettings) 134 | XCTAssertEqual( 135 | result.equations.count, 136 | 25, 137 | "Expected nuumber of created equations should be the same count set in the equationSettings" 138 | ) 139 | } 140 | 141 | func testMakeEquations_producesEveryEquationTypeSpecifiedInTheEquationSettings() { 142 | let factory = EquationsFactoryImp() 143 | // all permutations 144 | let subsets: [Set] = [ 145 | [.addition], [.subtraction], [.multiplication], [.division], [.addition, .subtraction], [.addition, .multiplication], 146 | [.addition, .division], [.addition, .subtraction, .multiplication], [.addition, .subtraction, .division], 147 | [.addition, .multiplication, .division], [.addition, .subtraction, .multiplication, .division], 148 | [.subtraction, .multiplication], [.subtraction, .division], [.subtraction, .multiplication, .division], 149 | [.multiplication, .division] 150 | ] 151 | 152 | for set in subsets { 153 | let equationSettings = EquationSettings.standard.set(\.equationTypes, to: set) 154 | let result = factory.makeEquations(usingSettings: equationSettings) 155 | XCTAssertTrue( 156 | result.equations.allSatisfy { set.contains($0.type) }, 157 | "Expected all equations to be one of [\(set.map(\.rawValue).joined(separator: ", "))]" 158 | ) 159 | } 160 | } 161 | 162 | func testMakeEquations_triesToProduceEqualAmountOfEquationsOfCertainType() { 163 | let factory = EquationsFactoryImp() 164 | let equationSettings = EquationSettings.standard 165 | .set(\.equationsCount, to: 12) 166 | .set(\.equationTypes, to: [.addition, .subtraction, .multiplication, .division]) 167 | let result = factory.makeEquations(usingSettings: equationSettings) 168 | let types = Dictionary(grouping: result.equations, by: \.type) 169 | // since there are 12 total equations, and 4 types, expected count is 12/4 = 3 equations of each type 170 | XCTAssertEqual(types[.addition]?.count, 3, "expected 3 addition equations") 171 | XCTAssertEqual(types[.subtraction]?.count, 3, "expected 3 subtraction equations") 172 | XCTAssertEqual(types[.multiplication]?.count, 3, "expected 3 multiplication equations") 173 | XCTAssertEqual(types[.division]?.count, 3, "expected 3 division equations") 174 | } 175 | 176 | func testMakeEquations_addition_RandomizedOperandsFallWithinExpectedValues() { 177 | let factory = EquationsFactoryImp() 178 | // larger sample of 50 equations to get more varriation 179 | let equationSettings = EquationSettings.standard 180 | .set(\.equationsCount, to: 50) 181 | .set(\.minimumDigit, to: 1) 182 | .set(\.maximumDigit, to: 5) 183 | .set(\.equationTypes, to: [.addition]) 184 | let result = factory.makeEquations(usingSettings: equationSettings) 185 | let expectedRange = (equationSettings.minimumDigit...equationSettings.maximumDigit) 186 | XCTAssertTrue( 187 | result.equations.allSatisfy { expectedRange.contains($0.left) || expectedRange.contains($0.right) }, 188 | "All left and right operands should be within range of minimum and maximum digit" 189 | ) 190 | } 191 | 192 | func testMakeEquations_subtraction_RandomizedOperandsFallWithinExpectedValues() { 193 | let factory = EquationsFactoryImp() 194 | // larger sample of 50 equations to get more varriation 195 | let equationSettings = EquationSettings.standard 196 | .set(\.equationsCount, to: 50) 197 | .set(\.minimumDigit, to: 1) 198 | .set(\.maximumDigit, to: 5) 199 | .set(\.equationTypes, to: [.subtraction]) 200 | let result = factory.makeEquations(usingSettings: equationSettings) 201 | let expectedLeftRange = (equationSettings.minimumDigit...equationSettings.maximumDigit) 202 | XCTAssertTrue( 203 | result.equations.allSatisfy { expectedLeftRange.contains($0.left) }, 204 | "All left operands should be within range of minimum and maximum digit" 205 | ) 206 | XCTAssertTrue( 207 | result.equations.allSatisfy { (equationSettings.minimumDigit...$0.left).contains($0.right) }, 208 | "All right operands should be within range of minimum digit and the right operand" 209 | ) 210 | } 211 | 212 | func testMakeEquations_multiplication_RandomizedOperandsFallWithinExpectedValues() { 213 | let factory = EquationsFactoryImp() 214 | // larger sample of 50 equations to get more varriation 215 | let equationSettings = EquationSettings.standard 216 | .set(\.equationsCount, to: 50) 217 | .set(\.minimumDigit, to: 1) 218 | .set(\.maximumDigit, to: 5) 219 | .set(\.equationTypes, to: [.multiplication]) 220 | let result = factory.makeEquations(usingSettings: equationSettings) 221 | let expectedRange = (equationSettings.minimumDigit...equationSettings.maximumDigit) 222 | XCTAssertTrue( 223 | result.equations.allSatisfy { expectedRange.contains($0.left) || expectedRange.contains($0.right) }, 224 | "All left and right operands should be within range of minimum and maximum digit" 225 | ) 226 | } 227 | 228 | func testMakeEquations_division_RandomizedOperandsFallWithinExpectedValues() { 229 | let factory = EquationsFactoryImp() 230 | // larger sample of 100 equations to get more varriation 231 | let equationSettings = EquationSettings.standard 232 | .set(\.equationsCount, to: 50) 233 | .set(\.minimumDigit, to: 0) 234 | .set(\.maximumDigit, to: 9) 235 | .set(\.equationTypes, to: [.division]) 236 | let result = factory.makeEquations(usingSettings: equationSettings) 237 | let expectedLeftRange = (equationSettings.minimumDigit...equationSettings.maximumDigit) 238 | XCTAssertTrue( 239 | result.equations.allSatisfy { expectedLeftRange.contains($0.left) }, 240 | "All left operands should be within range of minimum and maximum digit" 241 | ) 242 | XCTAssertTrue( 243 | result.equations.allSatisfy { 244 | $0.left == 0 245 | ? (equationSettings.minimumDigit + 1...equationSettings.maximumDigit).contains($0.right) 246 | : (equationSettings.minimumDigit + 1...$0.left).contains($0.right) 247 | }, 248 | """ 249 | If left operand is 0, all right operands should be within range of minimum and maximum digit (but not 0) 250 | Otherwise all right operands must be within range of minimum digit and left operand (but not 0) 251 | """ 252 | ) 253 | XCTAssertTrue(result.equations.allSatisfy { $0.left % $0.right == 0 }, "All division must be without remainder") 254 | XCTAssertTrue( 255 | result.equations.filter { $0.left.isPrime }.count < equationSettings.equationsCount / 2, 256 | "expected less exuations where left (dividend) is a prime number" 257 | ) 258 | } 259 | 260 | } 261 | 262 | private extension EquationSettings { 263 | static let standard = EquationSettings( 264 | minimumDigit: 0, 265 | maximumDigit: 9, 266 | equationsCount: 10, 267 | equationTypes: [.addition, .subtraction] 268 | ) 269 | } 270 | -------------------------------------------------------------------------------- /SimpleMathTests/App/Equations/SettingsBundle+EquationSettingsTests.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | @testable import SimpleMath 6 | import XCTest 7 | 8 | class SettingsBundleAndEquationSettingsTests: XCTestCase { 9 | 10 | func testEquationSettings_extractsOnlyEquationSettingsProperties() { 11 | let bundle = SettingsBundle( 12 | minimumDigit: 1, 13 | maximumDigit: 30, 14 | equationsCount: 16, 15 | equationTypes: [.division, .addition], 16 | areSoundsEnabled: true 17 | ) 18 | let expectedEquationSettings = EquationSettings( 19 | minimumDigit: 1, 20 | maximumDigit: 30, 21 | equationsCount: 16, 22 | equationTypes: [.addition, .division] 23 | ) 24 | XCTAssertEqual( 25 | bundle.equationSettings, 26 | expectedEquationSettings, 27 | "calling `equationSettings` on bundle should produce expected equationSettings" 28 | ) 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /SimpleMathTests/App/OnboardingTests.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | @testable import SimpleMath 6 | import XCTest 7 | 8 | class OnboardingTests: XCTestCase { 9 | 10 | func testInit_loadsPropertiesFromStorage() { 11 | var storageMock = StorageMock(onboardingBundle: OnboardingBundle(seenSettingsHint: true)) 12 | var onboarding = Onboarding(withStorage: storageMock) 13 | XCTAssertFalse( 14 | onboarding.showSettingsHint, 15 | "`showSettingsHint` should be false because `seenSettingsHint` is set to true" 16 | ) 17 | 18 | storageMock = StorageMock(onboardingBundle: OnboardingBundle(seenSettingsHint: false)) 19 | onboarding = Onboarding(withStorage: storageMock) 20 | XCTAssertTrue( 21 | onboarding.showSettingsHint, 22 | "`showSettingsHint` should be true because `seenSettingsHint` is set to false" 23 | ) 24 | } 25 | 26 | func testDiscardSettingsHint_setsShowSettingsHintToFalseAndUpdatesStorage() { 27 | let storageMock = StorageMock(onboardingBundle: OnboardingBundle(seenSettingsHint: false)) 28 | let onboarding = Onboarding(withStorage: storageMock) 29 | onboarding.discardSettingsHint() 30 | XCTAssertFalse( 31 | onboarding.showSettingsHint, 32 | "showSettingsHint should be set to false after discardSettingsHint is called" 33 | ) 34 | XCTAssertEqual( 35 | storageMock.onboardingBundle, 36 | OnboardingBundle(seenSettingsHint: true), 37 | "the storage should be updated with the changed onboarding bundle where seenSettingsHint is true" 38 | ) 39 | } 40 | 41 | func testDiscardSettingsHint_doesNothingIfShowSettingsHintWasFalse() { 42 | let storageMock = StorageMock(onboardingBundle: OnboardingBundle(seenSettingsHint: true)) 43 | let onboarding = Onboarding(withStorage: storageMock) 44 | 45 | XCTAssertFalse( 46 | onboarding.showSettingsHint, 47 | "showSettingsHint should still be false after discardSettingsHint is called" 48 | ) 49 | XCTAssertEqual( 50 | storageMock.onboardingBundle, 51 | OnboardingBundle(seenSettingsHint: true), 52 | "the storage should be not be updated" 53 | ) 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /SimpleMathTests/App/SettingsViewModelTests.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import Combine 6 | @testable import SimpleMath 7 | import XCTest 8 | 9 | class SettingsViewModelTests: XCTestCase { 10 | 11 | func testInit_initializesPropertiesFromSettingsCorrectly() { 12 | let bundle = SettingsBundle( 13 | minimumDigit: 10, 14 | maximumDigit: 20, 15 | equationsCount: 15, 16 | equationTypes: [.subtraction, .multiplication], 17 | areSoundsEnabled: false 18 | ) 19 | let settingsMock = SettingsMock() 20 | settingsMock.currentSettings = .just(bundle) 21 | let viewModel = SettingsViewModel(settings: settingsMock) 22 | 23 | XCTAssertEqual(viewModel.minRangeText, "10", "expected minimum range text to be 10") 24 | XCTAssertEqual(viewModel.maxRangeText, "20", "expected maximum range text to be 10") 25 | XCTAssertTrue(viewModel.isRangeValid, "10 is less than 20 so range should be valid") 26 | XCTAssertEqual(viewModel.numberOfEquations, 15, "expected numberOfEquations to be 15") 27 | XCTAssertFalse(viewModel.additionEnabled, "expected additionEnabled to be false") 28 | XCTAssertTrue(viewModel.subtractonEnabled, "expected subtractonEnabled to be true") 29 | XCTAssertTrue(viewModel.multiplicationEnabled, "expected multiplicationEnabled to be true") 30 | XCTAssertFalse(viewModel.divisionEnabled, "expected divisionEnabled to be false") 31 | XCTAssertFalse(viewModel.areSoundsEnabled, "expected areSoundsEnabled to be false") 32 | } 33 | 34 | func testUpdateMinRangeText_updatesTheMinRange_recalculatesIsRangeValid() { 35 | let bundle = SettingsBundle.default 36 | .set(\.minimumDigit, to: 1) 37 | .set(\.maximumDigit, to: 5) 38 | let settingsMock = SettingsMock() 39 | settingsMock.currentSettings = .just(bundle) 40 | let viewModel = SettingsViewModel(settings: settingsMock) 41 | XCTAssertEqual(viewModel.minRangeText, "1", "expected minimum range text to be 1") 42 | XCTAssertEqual(viewModel.maxRangeText, "5", "expected maximum range text to be 5") 43 | XCTAssertTrue(viewModel.isRangeValid, "1 is less than 5 so range should be valid") 44 | 45 | viewModel.updateMinRange(text: "3") 46 | 47 | XCTAssertEqual(viewModel.minRangeText, "3", "expected minimum range text to be 3") 48 | XCTAssertEqual(viewModel.maxRangeText, "5", "expected maximum range text to be 5") 49 | XCTAssertTrue(viewModel.isRangeValid, "3 is less than 5 so range should be valid") 50 | 51 | viewModel.updateMinRange(text: "10") 52 | 53 | XCTAssertEqual(viewModel.minRangeText, "10", "expected minimum range text to be 10") 54 | XCTAssertEqual(viewModel.maxRangeText, "5", "expected maximum range text to be 5") 55 | XCTAssertFalse(viewModel.isRangeValid, "10 is greater than 5 so range should be invalid") 56 | 57 | viewModel.updateMinRange(text: "5") 58 | 59 | XCTAssertEqual(viewModel.minRangeText, "5", "expected minimum range text to be 5") 60 | XCTAssertEqual(viewModel.maxRangeText, "5", "expected maximum range text to be 5") 61 | XCTAssertFalse(viewModel.isRangeValid, "5 is equal to 5 so range should be invalid") 62 | 63 | viewModel.updateMinRange(text: "") 64 | 65 | XCTAssertEqual(viewModel.minRangeText, "", "expected minimum range text is empty") 66 | XCTAssertEqual(viewModel.maxRangeText, "5", "expected maximum range text to be 10") 67 | XCTAssertFalse(viewModel.isRangeValid, "empty minimum range text makes range invalid") 68 | 69 | viewModel.updateMinRange(text: "0") 70 | 71 | XCTAssertEqual(viewModel.minRangeText, "0", "expected minimum range text to be 0") 72 | XCTAssertEqual(viewModel.maxRangeText, "5", "expected maximum range text to be 10") 73 | XCTAssertTrue(viewModel.isRangeValid, "0 is less than 5 so range should be valid") 74 | } 75 | 76 | func testUpdateMaxRangeText_updatesTheMinRange_recalculatesIsRangeValid() { 77 | let bundle = SettingsBundle.default 78 | .set(\.minimumDigit, to: 10) 79 | .set(\.maximumDigit, to: 20) 80 | let settingsMock = SettingsMock() 81 | settingsMock.currentSettings = .just(bundle) 82 | let viewModel = SettingsViewModel(settings: settingsMock) 83 | XCTAssertEqual(viewModel.minRangeText, "10", "expected minimum range text to be 10") 84 | XCTAssertEqual(viewModel.maxRangeText, "20", "expected maximum range text to be 20") 85 | XCTAssertTrue(viewModel.isRangeValid, "5 is greater than 1 so range should be valid") 86 | 87 | viewModel.updateMaxRange(text: "25") 88 | 89 | XCTAssertEqual(viewModel.minRangeText, "10", "expected minimum range text to be 10") 90 | XCTAssertEqual(viewModel.maxRangeText, "25", "expected maximum range text to be 25") 91 | XCTAssertTrue(viewModel.isRangeValid, "25 is greater than 10 so range should be valid") 92 | 93 | viewModel.updateMaxRange(text: "5") 94 | 95 | XCTAssertEqual(viewModel.minRangeText, "10", "expected minimum range text to be 10") 96 | XCTAssertEqual(viewModel.maxRangeText, "5", "expected maximum range text to be 5") 97 | XCTAssertFalse(viewModel.isRangeValid, "5 is less 10 so range should be invalid") 98 | 99 | viewModel.updateMaxRange(text: "10") 100 | 101 | XCTAssertEqual(viewModel.minRangeText, "10", "expected minimum range text to be 10") 102 | XCTAssertEqual(viewModel.maxRangeText, "10", "expected maximum range text to be 10") 103 | XCTAssertFalse(viewModel.isRangeValid, "10 is equal to 10 so range should be invalid") 104 | 105 | viewModel.updateMaxRange(text: "") 106 | 107 | XCTAssertEqual(viewModel.minRangeText, "10", "expected minimum range text to be 10") 108 | XCTAssertEqual(viewModel.maxRangeText, "", "expected maximum range text is empty") 109 | XCTAssertFalse(viewModel.isRangeValid, "empty maximum range text makes range invalid") 110 | 111 | viewModel.updateMaxRange(text: "99") 112 | 113 | XCTAssertEqual(viewModel.minRangeText, "10", "expected minimum range text to be 10") 114 | XCTAssertEqual(viewModel.maxRangeText, "99", "expected maximum range text to be 99") 115 | XCTAssertTrue(viewModel.isRangeValid, "99 is greater than 10 so range should be valid") 116 | 117 | viewModel.updateMaxRange(text: "100") 118 | 119 | XCTAssertEqual(viewModel.minRangeText, "10", "expected minimum range text to be 10") 120 | XCTAssertEqual(viewModel.maxRangeText, "99", "expected maximum range text to be 99, numbers above 100 are ignored") 121 | XCTAssertTrue(viewModel.isRangeValid, "99 is greater than 10 so range should be valid") 122 | } 123 | 124 | func testDecreaseNumberOfEquations_decreasesTheNumberOfEquationsNoLessThan5() { 125 | let bundle = SettingsBundle.default 126 | .set(\.equationsCount, to: 7) 127 | let settingsMock = SettingsMock() 128 | settingsMock.currentSettings = .just(bundle) 129 | let viewModel = SettingsViewModel(settings: settingsMock) 130 | XCTAssertEqual(viewModel.numberOfEquations, 7, "expected numberOfEquations to be 7") 131 | 132 | viewModel.decreaseNumberOfEquations() 133 | XCTAssertEqual(viewModel.numberOfEquations, 6, "expected numberOfEquations to be 6") 134 | viewModel.decreaseNumberOfEquations() 135 | XCTAssertEqual(viewModel.numberOfEquations, 5, "expected numberOfEquations to be 5") 136 | viewModel.decreaseNumberOfEquations() 137 | XCTAssertEqual(viewModel.numberOfEquations, 5, "expected numberOfEquations to still be 5, it is the lower limit") 138 | } 139 | 140 | func testIncreaseNumberOfEquations_increasesTheNumberOfEquationsNoMoreThan30() { 141 | let bundle = SettingsBundle.default 142 | .set(\.equationsCount, to: 28) 143 | let settingsMock = SettingsMock() 144 | settingsMock.currentSettings = .just(bundle) 145 | let viewModel = SettingsViewModel(settings: settingsMock) 146 | XCTAssertEqual(viewModel.numberOfEquations, 28, "expected numberOfEquations to be 28") 147 | 148 | viewModel.increaseNumberOfEquations() 149 | XCTAssertEqual(viewModel.numberOfEquations, 29, "expected numberOfEquations to be 29") 150 | viewModel.increaseNumberOfEquations() 151 | XCTAssertEqual(viewModel.numberOfEquations, 30, "expected numberOfEquations to be 30") 152 | viewModel.increaseNumberOfEquations() 153 | XCTAssertEqual(viewModel.numberOfEquations, 30, "expected numberOfEquations to still be 30, it is the upper limit") 154 | } 155 | 156 | func testEnableEquationType_enablesSpecificEquationType() { 157 | let bundle = SettingsBundle.default 158 | .set(\.equationTypes, to: [.division]) 159 | let settingsMock = SettingsMock() 160 | let settingsPublisher = CurrentValueSubject(bundle) 161 | settingsMock.currentSettings = settingsPublisher.eraseToAnyPublisher() 162 | let viewModel = SettingsViewModel(settings: settingsMock) 163 | 164 | XCTAssertFalse(viewModel.additionEnabled, "expected additionEnabled to be false") 165 | XCTAssertFalse(viewModel.subtractonEnabled, "expected subtractonEnabled to be false") 166 | XCTAssertFalse(viewModel.multiplicationEnabled, "expected multiplicationEnabled to be false") 167 | XCTAssertTrue(viewModel.divisionEnabled, "expected divisionEnabled to be true") 168 | 169 | viewModel.enableEquation(type: .addition) 170 | 171 | XCTAssertTrue(viewModel.additionEnabled, "expected additionEnabled to be true") 172 | XCTAssertFalse(viewModel.subtractonEnabled, "expected subtractonEnabled to be false") 173 | XCTAssertFalse(viewModel.multiplicationEnabled, "expected multiplicationEnabled to be false") 174 | XCTAssertTrue(viewModel.divisionEnabled, "expected divisionEnabled to be true") 175 | 176 | viewModel.enableEquation(type: .subtraction) 177 | 178 | XCTAssertTrue(viewModel.additionEnabled, "expected additionEnabled to be true") 179 | XCTAssertTrue(viewModel.subtractonEnabled, "expected subtractonEnabled to be true") 180 | XCTAssertFalse(viewModel.multiplicationEnabled, "expected multiplicationEnabled to be false") 181 | XCTAssertTrue(viewModel.divisionEnabled, "expected divisionEnabled to be true") 182 | 183 | viewModel.enableEquation(type: .multiplication) 184 | 185 | XCTAssertTrue(viewModel.additionEnabled, "expected additionEnabled to be true") 186 | XCTAssertTrue(viewModel.subtractonEnabled, "expected subtractonEnabled to be true") 187 | XCTAssertTrue(viewModel.multiplicationEnabled, "expected multiplicationEnabled to be true") 188 | XCTAssertTrue(viewModel.divisionEnabled, "expected divisionEnabled to be true") 189 | 190 | settingsPublisher.value = bundle.set(\.equationTypes, to: [.addition, .subtraction, .multiplication]) 191 | 192 | XCTAssertTrue(viewModel.additionEnabled, "expected additionEnabled to be true") 193 | XCTAssertTrue(viewModel.subtractonEnabled, "expected subtractonEnabled to be true") 194 | XCTAssertTrue(viewModel.multiplicationEnabled, "expected multiplicationEnabled to be true") 195 | XCTAssertFalse(viewModel.divisionEnabled, "expected divisionEnabled to be false") 196 | 197 | viewModel.enableEquation(type: .division) 198 | 199 | XCTAssertTrue(viewModel.additionEnabled, "expected additionEnabled to be true") 200 | XCTAssertTrue(viewModel.subtractonEnabled, "expected subtractonEnabled to be true") 201 | XCTAssertTrue(viewModel.multiplicationEnabled, "expected multiplicationEnabled to be true") 202 | XCTAssertTrue(viewModel.divisionEnabled, "expected divisionEnabled to be true") 203 | } 204 | 205 | func testDisableEquationType_disablesSpecificEquationType() { 206 | let bundle = SettingsBundle.default 207 | .set(\.equationTypes, to: [.addition, .subtraction, .multiplication, .division]) 208 | let settingsMock = SettingsMock() 209 | let settingsPublisher = CurrentValueSubject(bundle) 210 | settingsMock.currentSettings = settingsPublisher.eraseToAnyPublisher() 211 | let viewModel = SettingsViewModel(settings: settingsMock) 212 | 213 | XCTAssertTrue(viewModel.additionEnabled, "expected additionEnabled to be true") 214 | XCTAssertTrue(viewModel.subtractonEnabled, "expected subtractonEnabled to be true") 215 | XCTAssertTrue(viewModel.multiplicationEnabled, "expected multiplicationEnabled to be true") 216 | XCTAssertTrue(viewModel.divisionEnabled, "expected divisionEnabled to be true") 217 | 218 | viewModel.disableEquation(type: .addition) 219 | 220 | XCTAssertFalse(viewModel.additionEnabled, "expected additionEnabled to be false") 221 | XCTAssertTrue(viewModel.subtractonEnabled, "expected subtractonEnabled to be true") 222 | XCTAssertTrue(viewModel.multiplicationEnabled, "expected multiplicationEnabled to be true") 223 | XCTAssertTrue(viewModel.divisionEnabled, "expected divisionEnabled to be true") 224 | 225 | viewModel.disableEquation(type: .subtraction) 226 | 227 | XCTAssertFalse(viewModel.additionEnabled, "expected additionEnabled to be false") 228 | XCTAssertFalse(viewModel.subtractonEnabled, "expected subtractonEnabled to be false") 229 | XCTAssertTrue(viewModel.multiplicationEnabled, "expected multiplicationEnabled to be true") 230 | XCTAssertTrue(viewModel.divisionEnabled, "expected divisionEnabled to be true") 231 | 232 | viewModel.disableEquation(type: .multiplication) 233 | 234 | XCTAssertFalse(viewModel.additionEnabled, "expected additionEnabled to be false") 235 | XCTAssertFalse(viewModel.subtractonEnabled, "expected subtractonEnabled to be false") 236 | XCTAssertFalse(viewModel.multiplicationEnabled, "expected multiplicationEnabled to be false") 237 | XCTAssertTrue(viewModel.divisionEnabled, "expected divisionEnabled to be true") 238 | 239 | viewModel.disableEquation(type: .division) 240 | 241 | XCTAssertFalse(viewModel.additionEnabled, "expected additionEnabled to be false") 242 | XCTAssertFalse(viewModel.subtractonEnabled, "expected subtractonEnabled to be false") 243 | XCTAssertFalse(viewModel.multiplicationEnabled, "expected multiplicationEnabled to be false") 244 | XCTAssertTrue( 245 | viewModel.divisionEnabled, 246 | "division will still be enabled because it is not allowed to disable the last equation type, there must be at least 1" 247 | ) 248 | 249 | // enabling addition so that disabling .division is now possible, it is no longer the only equation type enabled 250 | viewModel.enableEquation(type: .addition) 251 | viewModel.disableEquation(type: .division) 252 | 253 | XCTAssertTrue(viewModel.additionEnabled, "expected additionEnabled to be true") 254 | XCTAssertFalse(viewModel.subtractonEnabled, "expected subtractonEnabled to be false") 255 | XCTAssertFalse(viewModel.multiplicationEnabled, "expected multiplicationEnabled to be false") 256 | XCTAssertFalse(viewModel.divisionEnabled, "expected divisionEnabled to be false") 257 | } 258 | 259 | func testUpdateSoundsEnabled_togglesSoundsOnAndOff() { 260 | let bundle = SettingsBundle.default.set(\.areSoundsEnabled, to: false) 261 | let settingsMock = SettingsMock() 262 | settingsMock.currentSettings = .just(bundle) 263 | let viewModel = SettingsViewModel(settings: settingsMock) 264 | XCTAssertFalse(viewModel.areSoundsEnabled, "initially the sounds are disabled") 265 | 266 | viewModel.updateSoundsEnabled(to: true) 267 | XCTAssertTrue(viewModel.areSoundsEnabled, "sounds should be enabled now") 268 | 269 | viewModel.updateSoundsEnabled(to: false) 270 | XCTAssertFalse(viewModel.areSoundsEnabled, "sounds should be dsiabled again") 271 | } 272 | 273 | func testCommitChanges_updatesSettingWithNewBundleCreatedFromCurrentProperties() { 274 | let settingsMock = SettingsMock() 275 | settingsMock.currentSettings = .just(.default) 276 | let viewModel = SettingsViewModel(settings: settingsMock) 277 | XCTAssertNil(settingsMock.updatedBundle, "before doing anything check if `updatedBundle` is nil for the mock") 278 | 279 | viewModel.disableEquation(type: .subtraction) 280 | viewModel.enableEquation(type: .multiplication) 281 | viewModel.increaseNumberOfEquations() 282 | viewModel.updateMinRange(text: "10") 283 | viewModel.updateMaxRange(text: "50") 284 | viewModel.updateSoundsEnabled(to: false) 285 | 286 | let expectedBundle = SettingsBundle( 287 | minimumDigit: 10, 288 | maximumDigit: 50, 289 | equationsCount: 11, 290 | equationTypes: [.addition, .multiplication], 291 | areSoundsEnabled: false 292 | ) 293 | viewModel.commitChanges() 294 | XCTAssertEqual( 295 | settingsMock.updatedBundle, expectedBundle, 296 | "settings should be updated with a new bundle created from the view model parameters" 297 | ) 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /SimpleMathTests/App/SimpleMathViewModelTests.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import Combine 6 | @testable import SimpleMath 7 | import XCTest 8 | 9 | class SimpleMathViewModelTests: XCTestCase { 10 | 11 | func testInit_equationsAreGeneratedUsingCorrectSettings() { 12 | let expectedEquations: [Equation] = [ 13 | Equation(left: 1, right: 3, operator: .add, answerDigitLimit: 2), 14 | Equation(left: 1, right: 5, operator: .multiply, answerDigitLimit: 2), 15 | Equation(left: 4, right: 2, operator: .subtract, answerDigitLimit: 2), 16 | Equation(left: 6, right: 3, operator: .divide, answerDigitLimit: 2) 17 | ] 18 | let expectedOperandDigitCount = 1 19 | let expectedAnswerDigitCount = 2 20 | let expectedEquationSettings = SettingsBundle.default.equationSettings 21 | let settingsMock = SettingsMock() 22 | settingsMock.currentSettings = .just(.default) 23 | let factoryMock = EquationsFactoryMock { 24 | GeneratedResult( 25 | equations: expectedEquations, 26 | maxOperandDigits: expectedOperandDigitCount, 27 | maxAnswerDigits: expectedAnswerDigitCount 28 | ) 29 | } 30 | let viewModel = SimpleMathViewModel( 31 | settings: settingsMock, 32 | audioPlayer: AudioEngineMock(), 33 | equationsFactory: factoryMock 34 | ) 35 | 36 | XCTAssertEqual(viewModel.equations, expectedEquations, 37 | "created equations should be the expected ones from the equation factory generated result") 38 | XCTAssertEqual(viewModel.currentEquationIndex, 0, "index always starts at 0") 39 | XCTAssertEqual(viewModel.operandDigitCount, expectedOperandDigitCount, 40 | "the operand digit count should be the same from the equation factory generated result") 41 | XCTAssertEqual(viewModel.answerDigitCount, expectedAnswerDigitCount, 42 | "the answer digit count should be the same from the equation factory generated result") 43 | XCTAssertEqual(factoryMock.equationSettings, expectedEquationSettings, 44 | "the factory should use the settings it was called with to generate the equations") 45 | } 46 | 47 | func testInputNumber_correctlyAddsDigitOnCurrentEquation() { 48 | let equations: [Equation] = [ .onePlusOne, .onePlusOne, .onePlusOne] 49 | let factoryMock = EquationsFactoryMock { 50 | GeneratedResult(equations: equations, maxOperandDigits: 1, maxAnswerDigits: 1) 51 | } 52 | let settingsMock = SettingsMock() 53 | settingsMock.currentSettings = .just(.default) 54 | let viewModel = SimpleMathViewModel( 55 | settings: settingsMock, 56 | audioPlayer: AudioEngineMock(), 57 | equationsFactory: 58 | factoryMock 59 | ) 60 | XCTAssertFalse(viewModel.commandsAvailable, "before inputing any numbers commands should be disabled") 61 | viewModel.input(number: 2) 62 | XCTAssertTrue(viewModel.commandsAvailable, "after inputing a number the commands should be enabled") 63 | XCTAssertEqual(viewModel.equations[0].currentAnswerText, "2", "expected current answer to be 2") 64 | XCTAssertEqual(viewModel.equations[1].currentAnswerText, "", "expected no answer") 65 | XCTAssertEqual(viewModel.equations[2].currentAnswerText, "", "expected no answer") 66 | } 67 | 68 | func testErase_correctlyRemovesDigitFromCurrentEquation() { 69 | let equations: [Equation] = [ .onePlusOne, .onePlusOne, .onePlusOne] 70 | let factoryMock = EquationsFactoryMock { 71 | GeneratedResult(equations: equations, maxOperandDigits: 1, maxAnswerDigits: 1) 72 | } 73 | let settingsMock = SettingsMock() 74 | settingsMock.currentSettings = .just(.default) 75 | let viewModel = SimpleMathViewModel( 76 | settings: settingsMock, 77 | audioPlayer: AudioEngineMock(), 78 | equationsFactory: 79 | factoryMock 80 | ) 81 | XCTAssertFalse(viewModel.commandsAvailable, "before inputing any numbers commands should be disabled") 82 | viewModel.input(number: 2) 83 | XCTAssertTrue(viewModel.commandsAvailable, "after inputing a number the commands should be enabled") 84 | viewModel.erase() 85 | XCTAssertFalse(viewModel.commandsAvailable, "after erasing the last digit the commands should be disabled") 86 | XCTAssertEqual(viewModel.equations[0].currentAnswerText, "", "expected no answer") 87 | XCTAssertEqual(viewModel.equations[1].currentAnswerText, "", "expected no answer") 88 | XCTAssertEqual(viewModel.equations[2].currentAnswerText, "", "expected no answer") 89 | } 90 | 91 | func testEvaluate_soundsEnabled_correctlyEvaluatesEquationAndPlaysSoundBasedOnEvaluation() { 92 | let equations: [Equation] = [ .onePlusOne, .onePlusOne, .onePlusOne] 93 | let factoryMock = EquationsFactoryMock { 94 | GeneratedResult(equations: equations, maxOperandDigits: 1, maxAnswerDigits: 1) 95 | } 96 | let settingsMock = SettingsMock() 97 | settingsMock.currentSettings = .just(.default) 98 | let audioEngineMock = AudioEngineMock() 99 | let viewModel = SimpleMathViewModel( 100 | settings: settingsMock, 101 | audioPlayer: audioEngineMock, 102 | equationsFactory: 103 | factoryMock 104 | ) 105 | XCTAssertFalse(viewModel.commandsAvailable, "before inputing any numbers commands should be disabled") 106 | viewModel.input(number: 2) 107 | XCTAssertTrue(viewModel.commandsAvailable, "after inputing a number the commands should be enabled") 108 | viewModel.evaluate() 109 | XCTAssertFalse(viewModel.commandsAvailable, "after evaluating an equation the commands should be disabled") 110 | viewModel.input(number: 1) 111 | XCTAssertTrue(viewModel.commandsAvailable, "after inputing a number the commands should be enabled") 112 | viewModel.evaluate() 113 | XCTAssertFalse(viewModel.commandsAvailable, "after evaluating an equation the commands should be disabled") 114 | XCTAssertEqual(viewModel.currentEquationIndex, 2, "after evaluating 2 equations the index should be at 2 (third)") 115 | XCTAssertEqual(viewModel.correctAnswers, 1, "after correctly evaluating single equation, correct answers should be 1") 116 | XCTAssertEqual(viewModel.progress, 2/3, "after evaluating 2 out fo 3 equations, progress should be 2/3 (in decimal)") 117 | XCTAssertFalse(viewModel.finished, "finished should be false since not all equations have been evaluated") 118 | XCTAssertFalse(viewModel.greatSuccess, "great seccess should be false since not all equations have been evaluated") 119 | XCTAssertEqual(viewModel.equations[0].currentAnswerText, "2", "expected current answer to be 2") 120 | XCTAssertTrue(viewModel.equations[0].finishedAnswering, "first equation should have finished answering true") 121 | XCTAssertEqual(viewModel.equations[1].currentAnswerText, "1", "expected current answer to be 1") 122 | XCTAssertTrue(viewModel.equations[1].finishedAnswering, "second equation should have finished answering true") 123 | XCTAssertEqual(viewModel.equations[2].currentAnswerText, "", "expected no answer") 124 | XCTAssertFalse(viewModel.equations[2].finishedAnswering, "third equation should have finished answering false") 125 | XCTAssertEqual( 126 | audioEngineMock.playedSounds, [.success, .failure], 127 | "expected sounds played are `success` after first equation evaluated correctly, `faulure` after second incorrectly" 128 | ) 129 | } 130 | 131 | func testEvaluate_soundsEnabled_correctlyEvaluatesAllEquations() { 132 | let equations: [Equation] = [ .onePlusOne, .onePlusOne, .onePlusOne] 133 | let factoryMock = EquationsFactoryMock { 134 | GeneratedResult(equations: equations, maxOperandDigits: 1, maxAnswerDigits: 1) 135 | } 136 | let settingsMock = SettingsMock() 137 | settingsMock.currentSettings = .just(.default) 138 | let audioEngineMock = AudioEngineMock() 139 | let viewModel = SimpleMathViewModel( 140 | settings: settingsMock, 141 | audioPlayer: audioEngineMock, 142 | equationsFactory: 143 | factoryMock 144 | ) 145 | viewModel.input(number: 2) 146 | viewModel.evaluate() 147 | viewModel.input(number: 2) 148 | viewModel.evaluate() 149 | viewModel.input(number: 2) 150 | viewModel.evaluate() 151 | XCTAssertEqual(viewModel.currentEquationIndex, 2, "after evaluating all equations the index should be at 2 (last)") 152 | XCTAssertEqual(viewModel.correctAnswers, 3, "after correctly evaluating 3 equations, correct answers should be 3") 153 | XCTAssertEqual(viewModel.progress, 1, "after evaluating all equations, progress should be 1 (100%)") 154 | XCTAssertTrue(viewModel.finished, "finished should be true since all equations have been evaluated") 155 | XCTAssertTrue(viewModel.greatSuccess, "great seccess should be true since all equations were correctly evaluated") 156 | XCTAssertEqual(viewModel.equations[0].currentAnswerText, "2", "expected current answer to be 2") 157 | XCTAssertTrue(viewModel.equations[0].finishedAnswering, "first equation should have finished answering true") 158 | XCTAssertEqual(viewModel.equations[1].currentAnswerText, "2", "expected current answer to be 2") 159 | XCTAssertTrue(viewModel.equations[1].finishedAnswering, "second equation should have finished answering true") 160 | XCTAssertEqual(viewModel.equations[2].currentAnswerText, "2", "expected current answer to be 2") 161 | XCTAssertTrue(viewModel.equations[2].finishedAnswering, "third equation should have finished answering true") 162 | XCTAssertEqual( 163 | audioEngineMock.playedSounds, [.success, .success, .greatSuccess], 164 | "expected sounds played are `success` for the first two correcrly evaluated equations, and `greatSuccess` at the end" 165 | ) 166 | } 167 | 168 | func testEvaluate_soundsEnabled_incorrectlyEvaluatesAllEquations() { 169 | let equations: [Equation] = [ .onePlusOne, .onePlusOne, .onePlusOne] 170 | let factoryMock = EquationsFactoryMock { 171 | GeneratedResult(equations: equations, maxOperandDigits: 1, maxAnswerDigits: 1) 172 | } 173 | let settingsMock = SettingsMock() 174 | settingsMock.currentSettings = .just(.default) 175 | let audioEngineMock = AudioEngineMock() 176 | let viewModel = SimpleMathViewModel( 177 | settings: settingsMock, 178 | audioPlayer: audioEngineMock, 179 | equationsFactory: 180 | factoryMock 181 | ) 182 | viewModel.input(number: 1) 183 | viewModel.evaluate() 184 | viewModel.input(number: 1) 185 | viewModel.evaluate() 186 | viewModel.input(number: 1) 187 | viewModel.evaluate() 188 | XCTAssertEqual(viewModel.currentEquationIndex, 2, "after evaluating all equations the index should be at 2 (last)") 189 | XCTAssertEqual(viewModel.correctAnswers, 0, "after correctly evaluating 0 equations, correct answers should be 0") 190 | XCTAssertEqual(viewModel.progress, 1, "after evaluating all equations, progress should be 1 (100%)") 191 | XCTAssertTrue(viewModel.finished, "finished should be true since all equations have been evaluated") 192 | XCTAssertFalse(viewModel.greatSuccess, "great seccess should be false since not all equations were correctly evaluated") 193 | XCTAssertEqual(viewModel.equations[0].currentAnswerText, "1", "expected current answer to be 1") 194 | XCTAssertTrue(viewModel.equations[0].finishedAnswering, "first equation should have finished answering true") 195 | XCTAssertEqual(viewModel.equations[1].currentAnswerText, "1", "expected current answer to be 1") 196 | XCTAssertTrue(viewModel.equations[1].finishedAnswering, "second equation should have finished answering true") 197 | XCTAssertEqual(viewModel.equations[2].currentAnswerText, "1", "expected current answer to be 1") 198 | XCTAssertTrue(viewModel.equations[2].finishedAnswering, "third equation should have finished answering true") 199 | XCTAssertEqual( 200 | audioEngineMock.playedSounds, [.failure, .failure, .failure], 201 | "expected sounds played are `failure` for all equations since all evaluated incorrectly") 202 | } 203 | 204 | func testEvaluate_soundsDisabled_correctlyEvaluatesAllEquationsButNoSoundsPlayed() { 205 | let equations: [Equation] = [ .onePlusOne, .onePlusOne, .onePlusOne] 206 | let factoryMock = EquationsFactoryMock { 207 | GeneratedResult(equations: equations, maxOperandDigits: 1, maxAnswerDigits: 1) 208 | } 209 | let settingsBundle = SettingsBundle.default.set(\.areSoundsEnabled, to: false) 210 | let settingsMock = SettingsMock() 211 | settingsMock.currentSettings = .just(settingsBundle) 212 | let audioEngineMock = AudioEngineMock() 213 | let viewModel = SimpleMathViewModel( 214 | settings: settingsMock, 215 | audioPlayer: audioEngineMock, 216 | equationsFactory: 217 | factoryMock 218 | ) 219 | viewModel.input(number: 2) 220 | viewModel.evaluate() 221 | viewModel.input(number: 2) 222 | viewModel.evaluate() 223 | viewModel.input(number: 2) 224 | viewModel.evaluate() 225 | XCTAssertTrue(audioEngineMock.playedSounds.isEmpty, "when sounds are disabled no sounds should be played") 226 | } 227 | 228 | func testReset_reCreatesEquationsSetsIndexToZeroAndResetsOtherParameters() { 229 | let equations: [Equation] = [ .onePlusOne, .onePlusOne, .onePlusOne] 230 | let factoryMock = EquationsFactoryMock { 231 | GeneratedResult(equations: equations, maxOperandDigits: 1, maxAnswerDigits: 1) 232 | } 233 | let settingsMock = SettingsMock() 234 | settingsMock.currentSettings = .just(.default) 235 | let audioEngineMock = AudioEngineMock() 236 | let viewModel = SimpleMathViewModel( 237 | settings: settingsMock, 238 | audioPlayer: audioEngineMock, 239 | equationsFactory: 240 | factoryMock 241 | ) 242 | viewModel.input(number: 2) 243 | viewModel.evaluate() 244 | viewModel.input(number: 2) 245 | viewModel.evaluate() 246 | viewModel.input(number: 2) 247 | viewModel.evaluate() 248 | XCTAssertEqual(viewModel.currentEquationIndex, 2, "after evaluating all equations the index should be at 2 (last)") 249 | XCTAssertEqual(viewModel.correctAnswers, 3, "after correctly evaluating 3 equations, correct answers should be 3") 250 | XCTAssertEqual(viewModel.progress, 1, "after evaluating all equations, progress should be 1 (100%)") 251 | XCTAssertTrue(viewModel.finished, "finishe should be true since all equations have been evaluated") 252 | XCTAssertTrue(viewModel.greatSuccess, "great seccess should be true since all equations were correctly evaluated") 253 | XCTAssertEqual(viewModel.equations[0].currentAnswerText, "2", "expected current answer to be 2") 254 | XCTAssertTrue(viewModel.equations[0].finishedAnswering, "first equation should have finished answering true") 255 | XCTAssertEqual(viewModel.equations[1].currentAnswerText, "2", "expected current answer to be 2") 256 | XCTAssertTrue(viewModel.equations[1].finishedAnswering, "second equation should have finished answering true") 257 | XCTAssertEqual(viewModel.equations[2].currentAnswerText, "2", "expected current answer to be 2") 258 | XCTAssertTrue(viewModel.equations[2].finishedAnswering, "third equation should have finished answering true") 259 | 260 | viewModel.reset() 261 | 262 | XCTAssertEqual(viewModel.equations[0].currentAnswerText, "", "expected no answer") 263 | XCTAssertFalse(viewModel.equations[0].finishedAnswering, "first equation should have finished answering false after reset") 264 | XCTAssertEqual(viewModel.equations[1].currentAnswerText, "", "expected no answer") 265 | XCTAssertFalse(viewModel.equations[1].finishedAnswering, "second equation should have finished answering false after reset") 266 | XCTAssertEqual(viewModel.equations[2].currentAnswerText, "", "expected no answer") 267 | XCTAssertFalse(viewModel.equations[2].finishedAnswering, "third equation should have finished answering false after reset") 268 | XCTAssertEqual(viewModel.equations, equations, "after reset the factory will regenerate new equations") 269 | XCTAssertEqual(viewModel.currentEquationIndex, 0, "after reset the index should be set to 0") 270 | XCTAssertEqual(viewModel.correctAnswers, 0, "after reset the correct answers should be 0") 271 | XCTAssertEqual(viewModel.progress, 0, "after resetting, progress should be 0") 272 | XCTAssertFalse(viewModel.finished, "finished should be false after a reset") 273 | XCTAssertFalse(viewModel.greatSuccess, "great seccess should be after a reset") 274 | } 275 | 276 | func testSettings_newSettingsPublishedThatChangeEquationSettingsWillResetCurrentEquations() { 277 | let settingsPublisher = CurrentValueSubject(.default) 278 | let settingsMock = SettingsMock() 279 | settingsMock.currentSettings = settingsPublisher.eraseToAnyPublisher() 280 | let viewModel = SimpleMathViewModel(settings: settingsMock, audioPlayer: AudioEngineMock()) 281 | XCTAssertEqual(viewModel.equations.count, 10, "default settings bundle has equations count set to 10") 282 | viewModel.input(number: 2) 283 | viewModel.evaluate() 284 | viewModel.input(number: 1) 285 | viewModel.evaluate() 286 | XCTAssertEqual(viewModel.currentEquationIndex, 2, "after evaluating 2 equations the index should be at 2 (third)") 287 | XCTAssertEqual(viewModel.progress, 2/10, "after evaluating 2 out fo 9 equations, progress should be 2/3 (in decimal)") 288 | XCTAssertFalse(viewModel.finished, "finished should be false since not all equations have been evaluated") 289 | XCTAssertFalse(viewModel.greatSuccess, "great seccess should be false since not all equations have been evaluated") 290 | XCTAssertEqual(viewModel.equations[0].currentAnswerText, "2", "expected current answer to be 2") 291 | XCTAssertTrue(viewModel.equations[0].finishedAnswering, "first equation should have finished answering true") 292 | XCTAssertEqual(viewModel.equations[1].currentAnswerText, "1", "expected current answer to be 1") 293 | XCTAssertTrue(viewModel.equations[1].finishedAnswering, "second equation should have finished answering true") 294 | XCTAssertEqual(viewModel.equations[2].currentAnswerText, "", "expected no answer") 295 | XCTAssertFalse(viewModel.equations[2].finishedAnswering, "third equation should have finished answering false") 296 | 297 | settingsPublisher.value = SettingsBundle.default.set(\.equationsCount, to: 5) 298 | 299 | XCTAssertEqual(viewModel.equations.count, 5, "new settings bundle has equations count set to 10") 300 | XCTAssertEqual(viewModel.equations[0].currentAnswerText, "", "expected no answer") 301 | XCTAssertFalse(viewModel.equations[0].finishedAnswering, "first equation should have finished answering false after reset") 302 | XCTAssertEqual(viewModel.equations[1].currentAnswerText, "", "expected no answer") 303 | XCTAssertFalse(viewModel.equations[1].finishedAnswering, "second equation should have finished answering false after reset") 304 | XCTAssertEqual(viewModel.equations[2].currentAnswerText, "", "expected no answer") 305 | XCTAssertFalse(viewModel.equations[2].finishedAnswering, "third equation should have finished answering false after reset") 306 | XCTAssertEqual(viewModel.currentEquationIndex, 0, "after settings update the index should be set to 0") 307 | XCTAssertEqual(viewModel.correctAnswers, 0, "after settings update the correct answers should be 0") 308 | XCTAssertEqual(viewModel.progress, 0, "after settings update, progress should be 0") 309 | XCTAssertFalse(viewModel.finished, "finished should be false after settings update") 310 | XCTAssertFalse(viewModel.greatSuccess, "great seccess should be after settings update") 311 | } 312 | 313 | } 314 | 315 | extension Equation { 316 | static let onePlusOne = Equation(left: 1, right: 1, operator: .add, answerDigitLimit: 1) 317 | } 318 | -------------------------------------------------------------------------------- /SimpleMathTests/Helpers/Builder.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | @testable import SimpleMath 6 | import Foundation 7 | 8 | protocol Builder {} 9 | 10 | extension Builder { 11 | func set(_ keyPath: WritableKeyPath, to newValue: T) -> Self { 12 | var copy = self 13 | copy[keyPath: keyPath] = newValue 14 | return copy 15 | } 16 | } 17 | 18 | extension SettingsBundle: Builder {} 19 | extension EquationSettings: Builder {} 20 | -------------------------------------------------------------------------------- /SimpleMathTests/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 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /SimpleMathTests/Settings/StoredSettingsTests.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | @testable import SimpleMath 6 | import XCTest 7 | 8 | class StoredSettingsTests: XCTestCase { 9 | 10 | func testInit_loadsInitialBundleFromStorageAndPublishesIt() { 11 | let storageMock = StorageMock(settingsBundle: .bundleSample1) 12 | let storedSettings = StoredSettings(withStorage: storageMock) 13 | let expectedValue: SettingsBundle = .bundleSample1 14 | var receivedValues: [SettingsBundle] = [] 15 | let subscription = storedSettings.currentSettings.sink(receiveValue: { receivedValues.append($0) }) 16 | subscription.cancel() 17 | XCTAssertEqual(receivedValues.count, 1, "Expected only a single value published by the `currentSettings` publisher") 18 | XCTAssertEqual(expectedValue, receivedValues.first, "`bundleSample1` value loaded from storage should be published") 19 | } 20 | 21 | func testUpdateSettings_ifNewSettingsAreDefferentFromCurrent_storesTheBundleAndPublishesTheNewlyUpdatedBundle() { 22 | let storageMock = StorageMock(settingsBundle: .bundleSample1) 23 | let storedSettings = StoredSettings(withStorage: storageMock) 24 | let expectedValues: [SettingsBundle] = [.bundleSample1, .bundleSample2] 25 | var receivedValues: [SettingsBundle] = [] 26 | let subscription = storedSettings.currentSettings.sink(receiveValue: { receivedValues.append($0) }) 27 | storedSettings.updateSettings(bundle: .bundleSample2) 28 | subscription.cancel() 29 | XCTAssertEqual(receivedValues.count, 2, "Expected 2 values published by the `currentSettings` publisher") 30 | XCTAssertEqual(expectedValues, receivedValues, "Expected published values `.bundleSample1` and `.bundleSample2`") 31 | XCTAssertEqual(storageMock.settingsBundle, .bundleSample2, "The new bundle `.bundleSample2` should be stored in storage") 32 | } 33 | 34 | func testUpdateSettings_ifNewSettingsAreSameAsCurrent_DoesNotStoreTheBundleAndDoesNotPublishesTheNewlyUpdatedBundle() { 35 | let storageMock = StorageMock(settingsBundle: .bundleSample1) 36 | let storedSettings = StoredSettings(withStorage: storageMock) 37 | let expectedValues: [SettingsBundle] = [.bundleSample1] 38 | var receivedValues: [SettingsBundle] = [] 39 | let subscription = storedSettings.currentSettings.sink(receiveValue: { receivedValues.append($0) }) 40 | storedSettings.updateSettings(bundle: .bundleSample1) 41 | subscription.cancel() 42 | XCTAssertEqual(receivedValues.count, 1, "Expected only a single value published by the `currentSettings` publisher") 43 | XCTAssertEqual(expectedValues, receivedValues, "Expected published value is only `.bundleSample1`") 44 | XCTAssertEqual(storageMock.settingsBundle, .bundleSample1, "The storage should still have the same `.bundleSample1`") 45 | } 46 | 47 | } 48 | 49 | private extension SettingsBundle { 50 | static let bundleSample1 = SettingsBundle( 51 | minimumDigit: 1, 52 | maximumDigit: 20, 53 | equationsCount: 20, 54 | equationTypes: [.addition, .multiplication], 55 | areSoundsEnabled: false 56 | ) 57 | 58 | static let bundleSample2 = SettingsBundle( 59 | minimumDigit: 1, 60 | maximumDigit: 30, 61 | equationsCount: 5, 62 | equationTypes: [.subtraction, .division], 63 | areSoundsEnabled: true 64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /SimpleMathTests/Settings/UserDefaultsStorageTests.swift: -------------------------------------------------------------------------------- 1 | // SimpleMath 2 | // Copyright (c) Filip Lazov 2020 3 | // MIT license - see LICENSE file for more info 4 | 5 | import Foundation 6 | @testable import SimpleMath 7 | import XCTest 8 | 9 | class UserDefaultsStorageTests: XCTestCase { 10 | 11 | override func tearDownWithError() throws { 12 | UserDefaults.standard.removeObject(forKey: .testKey) 13 | } 14 | 15 | func testStoreSettingsBundle_encodesBundleAndModelVersionToJsonAndStoresItForTheProvidedKey() throws { 16 | let defaults = UserDefaults.standard 17 | let storage = UserDefaultsStorage(withKey: .testKey, modelVersion: .testVersion) 18 | XCTAssertNil(defaults.object(forKey: .testKey), "A check if key value is empty before proceeding") 19 | storage.store(settingsBundle: .sample) 20 | let jsonData = try XCTUnwrap(defaults.object(forKey: .testKey) as? Data) 21 | let jsonObject = try JSONSerialization.jsonObject(with: jsonData, options: .mutableContainers) as? [String: Any] 22 | XCTAssertEqual(jsonObject?["modelVersion"] as? String, String.testVersion, "saved data should contain correct model version") 23 | 24 | let settingsBundleObject = try XCTUnwrap(jsonObject?["settingsBundle"]) 25 | let settingsBundleJsonData = try JSONSerialization.data(withJSONObject: settingsBundleObject, options: []) 26 | let decodedSettingsBundle = try JSONDecoder().decode(SettingsBundle.self, from: settingsBundleJsonData) 27 | XCTAssertEqual(decodedSettingsBundle, .sample, "the settings bundle should be stored with correct values in JSON format") 28 | } 29 | 30 | func testStoreOnboardingBundle_encodesBundleAndModelVersionToJsonAndStoresItForTheProvidedKey() throws { 31 | let defaults = UserDefaults.standard 32 | let storage = UserDefaultsStorage(withKey: .testKey, modelVersion: .testVersion) 33 | XCTAssertNil(defaults.object(forKey: .testKey), "A check if key value is empty before proceeding") 34 | storage.store(onboardingBundle: .sample) 35 | let jsonData = try XCTUnwrap(defaults.object(forKey: .testKey) as? Data) 36 | let jsonObject = try JSONSerialization.jsonObject(with: jsonData, options: .mutableContainers) as? [String: Any] 37 | XCTAssertEqual(jsonObject?["modelVersion"] as? String, String.testVersion, "saved data should contain correct model version") 38 | 39 | let onboardingBundleObject = try XCTUnwrap(jsonObject?["onboardingBundle"]) 40 | let onboardingBundleJsonData = try JSONSerialization.data(withJSONObject: onboardingBundleObject, options: []) 41 | let decodedOnboardingBundle = try JSONDecoder().decode(OnboardingBundle.self, from: onboardingBundleJsonData) 42 | XCTAssertEqual(decodedOnboardingBundle, .sample, "the onboarding bundle should be stored with correct values in JSON format") 43 | } 44 | 45 | func testLoadSettingsBundle_decodesAndReturnsBundle() { 46 | let defaults = UserDefaults.standard 47 | let storage = UserDefaultsStorage(withKey: .testKey, modelVersion: .testVersion) 48 | XCTAssertNil(defaults.object(forKey: .testKey), "A check if key value is empty before proceeding") 49 | let jsonData = String.sampleJson.data(using: .utf8) 50 | defaults.set(jsonData, forKey: .testKey) 51 | XCTAssertEqual(storage.loadSettingsBundle(), .sample, "loadSettingsBundle should correctly decode and return the `sample`") 52 | } 53 | 54 | func testLoadSettingsBundle_whenNothingWasSavedBefore_loadsDefaultSettingsBundle() { 55 | let defaults = UserDefaults.standard 56 | let storage = UserDefaultsStorage(withKey: .testKey, modelVersion: .testVersion) 57 | XCTAssertNil(defaults.object(forKey: .testKey), "A check if key value is empty before proceeding") 58 | XCTAssertEqual( 59 | storage.loadSettingsBundle(), 60 | .default, 61 | "loadSettingsBundle should return `default` if nothing is found for `testKey`" 62 | ) 63 | } 64 | 65 | func testLoadSettingsBundle_whenStoredDataIsCorrupted_loadsDefaultSettingsBundle() { 66 | let defaults = UserDefaults.standard 67 | let storage = UserDefaultsStorage(withKey: .testKey, modelVersion: .testVersion) 68 | XCTAssertNil(defaults.object(forKey: .testKey), "A check if key value is empty before proceeding") 69 | let jsonData = String.invalidJson.data(using: .utf8) 70 | defaults.set(jsonData, forKey: .testKey) 71 | XCTAssertEqual( 72 | storage.loadSettingsBundle(), 73 | .default, 74 | "loadSettingsBundle should return `default` if the underlying json is corrupted / invalid" 75 | ) 76 | } 77 | 78 | func testOnboardingBundle_decodesAndReturnsBundle() { 79 | let defaults = UserDefaults.standard 80 | let storage = UserDefaultsStorage(withKey: .testKey, modelVersion: .testVersion) 81 | XCTAssertNil(defaults.object(forKey: .testKey), "A check if key value is empty before proceeding") 82 | let jsonData = String.sampleJson.data(using: .utf8) 83 | defaults.set(jsonData, forKey: .testKey) 84 | XCTAssertEqual(storage.loadOnboardingBundle(), .sample, "loadSettingsBundle should correctly decode and return the `sample`") 85 | } 86 | 87 | func testLoadOnboardingBundle_whenNothingWasSavedBefore_loadsDefaultSettingsBundle() { 88 | let defaults = UserDefaults.standard 89 | let storage = UserDefaultsStorage(withKey: .testKey, modelVersion: .testVersion) 90 | XCTAssertNil(defaults.object(forKey: .testKey), "A check if key value is empty before proceeding") 91 | XCTAssertEqual( 92 | storage.loadOnboardingBundle(), 93 | .default, 94 | "loadOnboardingBundle should return `default` if nothing is found for `testKey`" 95 | ) 96 | } 97 | 98 | func testLoadOnboardingBundle_whenStoredDataIsCorrupted_loadsDefaultSettingsBundle() { 99 | let defaults = UserDefaults.standard 100 | let storage = UserDefaultsStorage(withKey: .testKey, modelVersion: .testVersion) 101 | XCTAssertNil(defaults.object(forKey: .testKey), "A check if key value is empty before proceeding") 102 | let jsonData = String.invalidJson.data(using: .utf8) 103 | defaults.set(jsonData, forKey: .testKey) 104 | XCTAssertEqual( 105 | storage.loadOnboardingBundle(), 106 | .default, 107 | "loadOnboardingBundle should return `default` if the underlying json is corrupted / invalid" 108 | ) 109 | } 110 | 111 | } 112 | 113 | private extension String { 114 | static let testKey = "test.key" 115 | static let testVersion = "9.9.9" 116 | 117 | static let sampleJson = 118 | """ 119 | { 120 | "modelVersion":"9.9.9", 121 | "settingsBundle": { 122 | "equationTypes": [ 123 | "multiplication", 124 | "addition"], 125 | "equationsCount": 20, 126 | "areSoundsEnabled": false, 127 | "minimumDigit": 1, 128 | "maximumDigit": 20 129 | }, 130 | "onboardingBundle": { 131 | "seenSettingsHint": true 132 | } 133 | } 134 | """ 135 | static let invalidJson = "{{{{}" 136 | } 137 | 138 | private extension SettingsBundle { 139 | static let sample = SettingsBundle( 140 | minimumDigit: 1, 141 | maximumDigit: 20, 142 | equationsCount: 20, 143 | equationTypes: [.addition, .multiplication], 144 | areSoundsEnabled: false 145 | ) 146 | } 147 | 148 | private extension OnboardingBundle { 149 | static let sample = OnboardingBundle(seenSettingsHint: true) 150 | } 151 | --------------------------------------------------------------------------------