├── .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 | [](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 |
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 |
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