├── apple.png ├── Conjugar ├── buzz.mp3 ├── gun.mp3 ├── info.png ├── quiz.png ├── verb.png ├── browse.png ├── chime.mp3 ├── chirp.mp3 ├── launch.png ├── applause1.mp3 ├── applause2.mp3 ├── applause3.mp3 ├── silence.mp3 ├── GameCenter.png ├── browseInfo.png ├── leaderboard.png ├── sadTrombone.mp3 ├── Assets.xcassets │ ├── Contents.json │ ├── Dancer.imageset │ │ ├── Dancer.png │ │ └── Contents.json │ ├── Info.imageset │ │ ├── Info@2x.png │ │ └── Contents.json │ ├── Quiz.imageset │ │ ├── Quiz@2x.png │ │ ├── Quiz@3x.png │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── icon29.png │ │ ├── icon1024.png │ │ ├── icon20@2x.png │ │ ├── icon20@3x.png │ │ ├── icon29-1.png │ │ ├── icon29@2x.png │ │ ├── icon29@3x.png │ │ ├── icon40@2x.png │ │ ├── icon40@3x.png │ │ ├── icon60@2x.png │ │ ├── icon60@3x.png │ │ ├── icon20@2x-1.png │ │ ├── icon20@2x-2.png │ │ ├── icon20@3x-1.png │ │ ├── icon20@3x-2.png │ │ ├── icon29@2x-1.png │ │ ├── icon29@2x-2.png │ │ ├── icon29@3x-1.png │ │ ├── icon29@3x-2.png │ │ ├── icon40@2x-1.png │ │ ├── icon40@2x-2.png │ │ ├── icon40@3x-1.png │ │ ├── icon40@3x-2.png │ │ ├── icon60@2x-1.png │ │ ├── icon60@2x-2.png │ │ ├── icon60@3x-1.png │ │ ├── icon60@3x-2.png │ │ └── Contents.json │ ├── Browse.imageset │ │ ├── Browse@2x.png │ │ ├── Browse@3x.png │ │ └── Contents.json │ └── Settings.imageset │ │ ├── Settings@2x.png │ │ └── Contents.json ├── es.lproj │ ├── Localizable.strings │ └── LaunchScreen.strings ├── AuxiliaryError.swift ├── QuizState.swift ├── InfoDelegate.swift ├── ReviewPromptable.swift ├── SecondSingularQuiz.swift ├── TestReviewPrompter.swift ├── GetterSetter.swift ├── VerbType.swift ├── Sound.swift ├── Layout.swift ├── NSCoderExtension.swift ├── UIViewControllerExtension.swift ├── main.swift ├── GameCenterable.swift ├── QuizDelegate.swift ├── ConjugatorError.swift ├── RealLocale.swift ├── UISegmentedControlExtension.swift ├── UsesAutoLayout.swift ├── UserDefaultsGetterSetter.swift ├── NSAttributedStringExtension.swift ├── Colors.swift ├── StubLocale.swift ├── SecondSingularBrowse.swift ├── Conjugar.entitlements ├── URLSessionExtension.swift ├── Locale.swift ├── DictionaryGetterSetter.swift ├── FontExtensions.swift ├── CommunGetter.swift ├── UILabelExtension.swift ├── TestAnalyticsService.swift ├── IntExtension.swift ├── Difficulty.swift ├── URLProtocolStub.swift ├── Region.swift ├── Commun.swift ├── TestGameCenter.swift ├── UIAlertControllerExtension.swift ├── ConjugationResult.swift ├── Emailer.swift ├── InfoCell.swift ├── Fonts.swift ├── InfoUIV.swift ├── UIApplicationExtensions.swift ├── UIViewExtensions.swift ├── Modifiers.swift ├── VerbCell.swift ├── SoundPlayer.swift ├── TenseCell.swift ├── Utterer.swift ├── ReviewPrompter.swift ├── PersonNumber.swift ├── AWSAnalyticsService.swift ├── InfoVC.swift ├── Info.plist ├── AppDelegate.swift ├── ResultsVC.swift ├── BrowseVerbsView.swift ├── ConjugationCell.swift ├── MainTabBarVC.swift ├── ResultCell.swift ├── RatingsFetcher.swift ├── CommunViewModel.swift ├── BrowseInfoUIV.swift ├── GameCenter.swift ├── BrowseVerbsVC.swift ├── VerbFamilies.swift ├── CommunVC.swift ├── ResultsUIV.swift ├── VerbUIV.swift ├── ConjugationDataSource.swift ├── AnalyticsServiceable.swift ├── BrowseInfoVC.swift ├── World.swift ├── VerbVC.swift ├── Base.lproj │ └── LaunchScreen.storyboard └── CloudCommunGetter.swift ├── Conjugar.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── WorkspaceSettings.xcsettings └── xcshareddata │ └── xcschemes │ └── Conjugar.xcscheme ├── .swiftlint.yml ├── ConjugarTests ├── Views │ └── SettingsViewTests.swift ├── Analytics │ ├── DeviceUtilityTests.swift │ └── AnalyticsServiceableTests.swift ├── UIViews │ ├── InfoCellTests.swift │ ├── VerbUIVTests.swift │ ├── VerbCellTests.swift │ ├── TenseCellTests.swift │ ├── ConjugationCellTests.swift │ └── ResultCellTests.swift ├── Helpers │ ├── MockNavigationC.swift │ └── UIColorExtension.swift ├── Supporting │ ├── TestingRootViewController.swift │ └── TestingAppDelegate.swift ├── Utils │ ├── IntExtensionTests.swift │ ├── UsesAutoLayoutTests.swift │ ├── UIViewControllerExtensionsTests.swift │ ├── UserDefaultsGetterSetterTests.swift │ ├── UIViewExtensionsTests.swift │ ├── TestGameCenterTests.swift │ ├── UIAlertControllerExtensionTests.swift │ ├── RatingsFetcherTests.swift │ └── ReviewPrompterTests.swift ├── Info.plist ├── Models │ ├── ConjugationResultTests.swift │ ├── PersonNumberTests.swift │ ├── QuizTests.swift │ └── TenseTests.swift └── Controllers │ ├── VerbVCTests.swift │ ├── BrowseVerbsVCTests.swift │ ├── InfoVCTests.swift │ ├── CommunViewModelTests.swift │ ├── ResultsVCTests.swift │ ├── BrowseInfoVCTests.swift │ ├── MainTabBarVCTests.swift │ ├── CommunVCTests.swift │ └── QuizVCTests.swift ├── .gitignore ├── ConjugarUITests ├── Info.plist └── QuizVCUITests.swift ├── privacyPolicy.md ├── privacyPolicy2.html └── README.md /apple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/apple.png -------------------------------------------------------------------------------- /Conjugar/buzz.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/buzz.mp3 -------------------------------------------------------------------------------- /Conjugar/gun.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/gun.mp3 -------------------------------------------------------------------------------- /Conjugar/info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/info.png -------------------------------------------------------------------------------- /Conjugar/quiz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/quiz.png -------------------------------------------------------------------------------- /Conjugar/verb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/verb.png -------------------------------------------------------------------------------- /Conjugar/browse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/browse.png -------------------------------------------------------------------------------- /Conjugar/chime.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/chime.mp3 -------------------------------------------------------------------------------- /Conjugar/chirp.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/chirp.mp3 -------------------------------------------------------------------------------- /Conjugar/launch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/launch.png -------------------------------------------------------------------------------- /Conjugar/applause1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/applause1.mp3 -------------------------------------------------------------------------------- /Conjugar/applause2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/applause2.mp3 -------------------------------------------------------------------------------- /Conjugar/applause3.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/applause3.mp3 -------------------------------------------------------------------------------- /Conjugar/silence.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/silence.mp3 -------------------------------------------------------------------------------- /Conjugar/GameCenter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/GameCenter.png -------------------------------------------------------------------------------- /Conjugar/browseInfo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/browseInfo.png -------------------------------------------------------------------------------- /Conjugar/leaderboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/leaderboard.png -------------------------------------------------------------------------------- /Conjugar/sadTrombone.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/sadTrombone.mp3 -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Conjugar/es.lproj/Localizable.strings: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/es.lproj/Localizable.strings -------------------------------------------------------------------------------- /Conjugar/es.lproj/LaunchScreen.strings: -------------------------------------------------------------------------------- 1 | 2 | /* Class = "UILabel"; text = "Conjugar"; ObjectID = "Fn5-yC-EuV"; */ 3 | "Fn5-yC-EuV.text" = "Conjugar"; 4 | -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/Dancer.imageset/Dancer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/Dancer.imageset/Dancer.png -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/Info.imageset/Info@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/Info.imageset/Info@2x.png -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/Quiz.imageset/Quiz@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/Quiz.imageset/Quiz@2x.png -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/Quiz.imageset/Quiz@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/Quiz.imageset/Quiz@3x.png -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/AppIcon.appiconset/icon29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/AppIcon.appiconset/icon29.png -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/Browse.imageset/Browse@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/Browse.imageset/Browse@2x.png -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/Browse.imageset/Browse@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/Browse.imageset/Browse@3x.png -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/AppIcon.appiconset/icon1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/AppIcon.appiconset/icon1024.png -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/AppIcon.appiconset/icon20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/AppIcon.appiconset/icon20@2x.png -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/AppIcon.appiconset/icon20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/AppIcon.appiconset/icon20@3x.png -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/AppIcon.appiconset/icon29-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/AppIcon.appiconset/icon29-1.png -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/AppIcon.appiconset/icon29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/AppIcon.appiconset/icon29@2x.png -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/AppIcon.appiconset/icon29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/AppIcon.appiconset/icon29@3x.png -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/AppIcon.appiconset/icon40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/AppIcon.appiconset/icon40@2x.png -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/AppIcon.appiconset/icon40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/AppIcon.appiconset/icon40@3x.png -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/AppIcon.appiconset/icon60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/AppIcon.appiconset/icon60@2x.png -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/AppIcon.appiconset/icon60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/AppIcon.appiconset/icon60@3x.png -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/AppIcon.appiconset/icon20@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/AppIcon.appiconset/icon20@2x-1.png -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/AppIcon.appiconset/icon20@2x-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/AppIcon.appiconset/icon20@2x-2.png -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/AppIcon.appiconset/icon20@3x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/AppIcon.appiconset/icon20@3x-1.png -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/AppIcon.appiconset/icon20@3x-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/AppIcon.appiconset/icon20@3x-2.png -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/AppIcon.appiconset/icon29@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/AppIcon.appiconset/icon29@2x-1.png -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/AppIcon.appiconset/icon29@2x-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/AppIcon.appiconset/icon29@2x-2.png -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/AppIcon.appiconset/icon29@3x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/AppIcon.appiconset/icon29@3x-1.png -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/AppIcon.appiconset/icon29@3x-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/AppIcon.appiconset/icon29@3x-2.png -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/AppIcon.appiconset/icon40@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/AppIcon.appiconset/icon40@2x-1.png -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/AppIcon.appiconset/icon40@2x-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/AppIcon.appiconset/icon40@2x-2.png -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/AppIcon.appiconset/icon40@3x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/AppIcon.appiconset/icon40@3x-1.png -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/AppIcon.appiconset/icon40@3x-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/AppIcon.appiconset/icon40@3x-2.png -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/AppIcon.appiconset/icon60@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/AppIcon.appiconset/icon60@2x-1.png -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/AppIcon.appiconset/icon60@2x-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/AppIcon.appiconset/icon60@2x-2.png -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/AppIcon.appiconset/icon60@3x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/AppIcon.appiconset/icon60@3x-1.png -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/AppIcon.appiconset/icon60@3x-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/AppIcon.appiconset/icon60@3x-2.png -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/Settings.imageset/Settings@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vermont42/Conjugar/HEAD/Conjugar/Assets.xcassets/Settings.imageset/Settings@2x.png -------------------------------------------------------------------------------- /Conjugar.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Conjugar/AuxiliaryError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuxiliaryError.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 4/9/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | enum AuxiliaryError: Error { 10 | case noHaberForm(Tense) 11 | } 12 | -------------------------------------------------------------------------------- /Conjugar/QuizState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QuizState.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 6/17/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | enum QuizState { 10 | case notStarted 11 | case inProgress 12 | case finished 13 | } 14 | -------------------------------------------------------------------------------- /Conjugar/InfoDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InfoDelegate.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 7/3/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | protocol InfoDelegate: AnyObject { 10 | func infoSelectionDidChange(newHeading: String) 11 | } 12 | -------------------------------------------------------------------------------- /Conjugar/ReviewPromptable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReviewPromptable.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 11/28/18. 6 | // Copyright © 2018 Josh Adams. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol ReviewPromptable { 12 | func promptableActionHappened() 13 | } 14 | -------------------------------------------------------------------------------- /Conjugar/SecondSingularQuiz.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SecondSingularQuiz.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 1/8/18. 6 | // Copyright © 2018 Josh Adams. All rights reserved. 7 | // 8 | 9 | enum SecondSingularQuiz: String, CaseIterable { 10 | case tu = "Tú" 11 | case vos = "Vos" 12 | } 13 | -------------------------------------------------------------------------------- /Conjugar.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Conjugar.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BuildSystemType 6 | Latest 7 | 8 | 9 | -------------------------------------------------------------------------------- /Conjugar/TestReviewPrompter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestReviewPrompter.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 11/28/18. 6 | // Copyright © 2018 Josh Adams. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class TestReviewPrompter: ReviewPromptable { 12 | func promptableActionHappened() {} 13 | } 14 | -------------------------------------------------------------------------------- /Conjugar/GetterSetter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GetterSetter.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 1/13/19. 6 | // Copyright © 2019 Josh Adams. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol GetterSetter { 12 | func get(key: String) -> String? 13 | func set(key: String, value: String) 14 | } 15 | -------------------------------------------------------------------------------- /Conjugar/VerbType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VerbType.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 5/6/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | enum VerbType: String { 10 | case irregular = "ir" 11 | case regularAr = "ra" 12 | case regularEr = "re" 13 | case regularIr = "ri" 14 | static let key = "vt" 15 | } 16 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | 2 | disabled_rules: 3 | - line_length 4 | - function_body_length 5 | - function_parameter_count 6 | - type_body_length 7 | - identifier_name 8 | - cyclomatic_complexity 9 | - todo 10 | - large_tuple 11 | - void_return 12 | - nesting 13 | - blanket_disable_command 14 | - for_where 15 | 16 | file_length: 17 | warning: 10000 18 | error: 100000 19 | -------------------------------------------------------------------------------- /Conjugar/Sound.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Sound.swift 3 | // Conjugar 4 | // 5 | // Created by Josh Adams on 4/9/16. 6 | // Copyright © 2016 Josh Adams. All rights reserved. 7 | // 8 | 9 | enum Sound: String { 10 | case applause1 11 | case applause2 12 | case applause3 13 | case chime 14 | case chirp 15 | case buzz 16 | case gun 17 | case sadTrombone 18 | case silence 19 | } 20 | -------------------------------------------------------------------------------- /Conjugar/Layout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Layout.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 8/8/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | struct Layout { 12 | static let defaultSpacing: CGFloat = 8.0 13 | static let tripleDefaultSpacing: CGFloat = 24.0 14 | static let defaultHorizontalMargin: CGFloat = 16.0 15 | } 16 | -------------------------------------------------------------------------------- /Conjugar/NSCoderExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSCoderExtension.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 11/11/19. 6 | // Copyright © 2019 Josh Adams. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension NSCoder { 12 | static func fatalErrorNotImplemented() -> Never { 13 | fatalError("init(coder:) has not been implemented") 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Conjugar/UIViewControllerExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIViewControllerExtension.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 11/11/18. 6 | // Copyright © 2018 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UIViewController { 12 | func fatalCastMessage(view: Any) -> String { 13 | return "Could not cast \(self).view to \(view)." 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/Dancer.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "Dancer.png", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/Info.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "Info@2x.png", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Conjugar/main.swift: -------------------------------------------------------------------------------- 1 | // 2 | // main.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 1/31/20. 6 | // Copyright © 2020 Josh Adams. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | import UIKit 12 | 13 | let appDelegateClass: AnyClass = NSClassFromString("TestingAppDelegate") ?? AppDelegate.self 14 | 15 | UIApplicationMain(CommandLine.argc, CommandLine.unsafeArgv, nil, NSStringFromClass(appDelegateClass)) 16 | -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/Settings.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "Settings@2x.png", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Conjugar/GameCenterable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GameCenterable.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 11/26/18. 6 | // Copyright © 2018 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | protocol GameCenterable { 12 | var isAuthenticated: Bool { get set } 13 | func authenticate(onViewController: UIViewController, completion: ((Bool) -> Void)?) 14 | func reportScore(_ score: Int) 15 | func showLeaderboard() 16 | } 17 | -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/Quiz.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "Quiz@2x.png", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "filename" : "Quiz@3x.png", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "version" : 1, 20 | "author" : "xcode" 21 | } 22 | } -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/Browse.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "filename" : "Browse@2x.png", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "filename" : "Browse@3x.png", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "version" : 1, 20 | "author" : "xcode" 21 | } 22 | } -------------------------------------------------------------------------------- /Conjugar/QuizDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QuizDelegate.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 6/17/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | protocol QuizDelegate: AnyObject { 10 | func scoreDidChange(newScore: Int) 11 | func timeDidChange(newTime: Int) 12 | func progressDidChange(current: Int, total: Int) 13 | func questionDidChange(verb: String, tense: Tense, personNumber: PersonNumber) 14 | func quizDidFinish() 15 | } 16 | -------------------------------------------------------------------------------- /ConjugarTests/Views/SettingsViewTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsViewTests.swift 3 | // ConjugarTests 4 | // 5 | // Created by Joshua Adams on 11/27/19. 6 | // Copyright © 2019 Josh Adams. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Conjugar 11 | 12 | class SettingsViewTests: XCTestCase { 13 | func testInitialization() { 14 | let settingsView = SettingsView() 15 | XCTAssertNotNil(settingsView) 16 | XCTAssertNotNil(settingsView.body) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Analytics 2 | AWSPinpoint.framework/ 3 | AWSCore.framework/ 4 | amplify/ 5 | awsconfiguration.json 6 | .amplifyrc 7 | 8 | # Other 9 | .DS_Store 10 | Conjugar.xcodeproj/project.xcworkspace/xcuserdata/ 11 | Conjugar.xcodeproj/xcuserdata/ 12 | 13 | #amplify 14 | amplify/\#current-cloud-backend 15 | amplify/.config/local-* 16 | amplify/backend/amplify-meta.json 17 | amplify/backend/awscloudformation 18 | build/ 19 | dist/ 20 | node_modules/ 21 | aws-exports.js 22 | awsconfiguration.json 23 | -------------------------------------------------------------------------------- /Conjugar/ConjugatorError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConjugatorError.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 4/2/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | enum ConjugatorError: Error { 10 | case tooShort 11 | case invalidEnding(String) 12 | case tenseNotImplemented(Tense) 13 | case noSuchConjugation(PersonNumber) 14 | case personNumberAbsent(Tense) 15 | case defectiveForPersonNumber(PersonNumber) 16 | case noFirstPersonSingularImperative 17 | } 18 | -------------------------------------------------------------------------------- /ConjugarTests/Analytics/DeviceUtilityTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeviceUtilityTests.swift 3 | // ConjugarTests 4 | // 5 | // Created by Joshua Adams on 4/24/19. 6 | // Copyright © 2019 Josh Adams. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Conjugar 11 | 12 | class DeviceUtilityTests: XCTestCase { 13 | func testModelName() { 14 | // This won't work if tests run on device. But in my case, they don't. 15 | XCTAssertEqual(UIDevice.current.modelName, "Simulator") 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Conjugar/RealLocale.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RealLocale.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 12/15/20. 6 | // Copyright © 2020 Josh Adams. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct RealLocale: Locale { 12 | private let none = "none" 13 | private let NONE = "NONE" 14 | 15 | var languageCode: String { 16 | NSLocale.current.language.languageCode?.identifier ?? none 17 | } 18 | 19 | var regionCode: String { 20 | NSLocale.current.region?.identifier ?? NONE 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Conjugar/UISegmentedControlExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UISegmentedControlExtension.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 9/29/19. 6 | // Copyright © 2019 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UISegmentedControl { 12 | func yellowfyText() { 13 | let titleTextAttributes = [NSAttributedString.Key.foregroundColor: Colors.yellow] 14 | setTitleTextAttributes(titleTextAttributes, for: .normal) 15 | setTitleTextAttributes(titleTextAttributes, for: .selected) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Conjugar/UsesAutoLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UsesAutoLayout.swift 3 | // Conjugar 4 | // 5 | // Shared by Antoine van der Lee in 2019. 6 | // 7 | 8 | import UIKit 9 | 10 | @propertyWrapper 11 | public struct UsesAutoLayout { 12 | public var wrappedValue: T { 13 | didSet { 14 | wrappedValue.translatesAutoresizingMaskIntoConstraints = false 15 | } 16 | } 17 | 18 | public init(wrappedValue: T) { 19 | self.wrappedValue = wrappedValue 20 | wrappedValue.translatesAutoresizingMaskIntoConstraints = false 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /ConjugarTests/UIViews/InfoCellTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InfoCellTests.swift 3 | // ConjugarTests 4 | // 5 | // Created by Joshua Adams on 5/22/19. 6 | // Copyright © 2019 Josh Adams. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Conjugar 11 | 12 | class InfoCellTests: XCTestCase { 13 | func testInfoCell() { 14 | let cell = InfoCell(style: .default, reuseIdentifier: "cell") 15 | cell.configure(heading: "Here is an info heading.") 16 | XCTAssertEqual(cell.heading.text, "Here is an info heading.") 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Conjugar/UserDefaultsGetterSetter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserDefaultsGetterSetter.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 1/13/19. 6 | // Copyright © 2019 Josh Adams. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class UserDefaultsGetterSetter: GetterSetter { 12 | private let userDefaults = UserDefaults.standard 13 | 14 | func get(key: String) -> String? { 15 | return userDefaults.string(forKey: key) 16 | } 17 | 18 | func set(key: String, value: String) { 19 | userDefaults.set(value, forKey: key) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /ConjugarTests/Helpers/MockNavigationC.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockNavigationC.swift 3 | // ConjugarTests 4 | // 5 | // Created by Joshua Adams on 8/27/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class MockNavigationC: UINavigationController { 12 | var pushedViewController: UIViewController? 13 | 14 | override func pushViewController(_ viewController: UIViewController, animated: Bool) { 15 | pushedViewController = viewController 16 | super.pushViewController(viewController, animated: true) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Conjugar/NSAttributedStringExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSAttributedStringExtension.swift 3 | // Conjugar 4 | // 5 | // Created by Adams, Josh on 5/13/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | func + (left: NSAttributedString, right: NSAttributedString) -> NSAttributedString { 12 | let result = NSMutableAttributedString() 13 | result.append(left) 14 | result.append(right) 15 | return result 16 | } 17 | 18 | func += (lhs: inout NSAttributedString, rhs: NSAttributedString) { 19 | return lhs = lhs + rhs 20 | } 21 | -------------------------------------------------------------------------------- /Conjugar/Colors.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Colors.swift 3 | // Conjugar 4 | // 5 | // Created by Adams, Josh on 5/13/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | public struct Colors { 12 | static let red = UIColor(red: 193/255, green: 0/255, blue: 29/255, alpha: 1.0) 13 | static let yellow = UIColor(red: 205/255, green: 165/255, blue: 27/255, alpha: 1.0) 14 | static let blue = UIColor(red: 85/255, green: 135/255, blue: 255/255, alpha: 1.0) 15 | static let black = UIColor(red: 0/255, green: 0/255, blue: 0/255, alpha: 1.0) 16 | } 17 | -------------------------------------------------------------------------------- /Conjugar/StubLocale.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StubLocale.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 12/15/20. 6 | // Copyright © 2020 Josh Adams. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct StubLocale: Locale { 12 | var languageCode: String 13 | var regionCode: String 14 | private static let english = "en" 15 | private static let america = "US" 16 | 17 | init(languageCode: String = StubLocale.english, regionCode: String = StubLocale.america) { 18 | self.languageCode = languageCode 19 | self.regionCode = regionCode 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Conjugar/SecondSingularBrowse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SecondSingularBrowse.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 1/8/18. 6 | // Copyright © 2018 Josh Adams. All rights reserved. 7 | // 8 | 9 | enum SecondSingularBrowse: String, CaseIterable { 10 | case tu = "Tú" 11 | case vos = "Vos" 12 | case both = "Both" 13 | 14 | var localizedSecondSingularBrowse: String { 15 | switch self { 16 | case .tu: 17 | return rawValue 18 | case .vos: 19 | return rawValue 20 | case .both: 21 | return Localizations.bothFeminine 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /ConjugarTests/UIViews/VerbUIVTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VerbUIVTests.swift 3 | // ConjugarTests 4 | // 5 | // Created by Joshua Adams on 9/4/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Conjugar 11 | 12 | class VerbUIVTests: XCTestCase { 13 | func testVerbVC() { 14 | Current = World.unitTest 15 | let vvc = VerbVC(verb: "maltear") 16 | let verbView = vvc.verbView 17 | XCTAssertNotNil(verbView) 18 | let expectedSubviewCount = 8 19 | XCTAssertEqual(verbView.subviews.count, expectedSubviewCount) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /ConjugarTests/Supporting/TestingRootViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestingRootViewController.swift 3 | // ConjugarTests 4 | // 5 | // Created by Joshua Adams on 1/31/20. 6 | // Copyright © 2020 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | @testable import Conjugar 11 | 12 | class TestingRootViewController: UIViewController { 13 | override func loadView() { 14 | let label = UILabel() 15 | label.text = "Running unit tests..." 16 | label.textAlignment = .center 17 | label.textColor = Colors.yellow 18 | label.font = Fonts.heading 19 | view = label 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Conjugar/Conjugar.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | development 7 | com.apple.developer.game-center 8 | 9 | com.apple.developer.icloud-container-identifiers 10 | 11 | iCloud.biz.Conjugar 12 | 13 | com.apple.developer.icloud-services 14 | 15 | CloudKit 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /Conjugar/URLSessionExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLSessionExtension.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 5/1/19. 6 | // Copyright © 2019 Josh Adams. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension URLSession { 12 | static func stubSession(ratingsCount: Int) -> URLSession { 13 | URLProtocolStub.testURLs = [RatingsFetcher.iTunesURL: RatingsFetcher.stubData(ratingsCount: ratingsCount)] 14 | let config = URLSessionConfiguration.ephemeral 15 | config.protocolClasses = [URLProtocolStub.self] 16 | return URLSession(configuration: config) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /ConjugarTests/UIViews/VerbCellTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VerbCellTests.swift 3 | // ConjugarTests 4 | // 5 | // Created by Joshua Adams on 9/5/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Conjugar 11 | 12 | class VerbCellTests: XCTestCase { 13 | func testVerbCell() { 14 | let cell = VerbCell(style: .default, reuseIdentifier: "cell") 15 | cell.configure(verb: "maltear") 16 | XCTAssertEqual(cell.verb.text, "maltear") 17 | XCTAssertEqual(cell.verb.textColor, Colors.yellow) 18 | XCTAssertEqual(cell.verb.font, Fonts.largeCell) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Conjugar/Locale.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Locale.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 12/15/20. 6 | // Copyright © 2020 Josh Adams. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol Locale { 12 | var locale: String { get } 13 | var languageCode: String { get } 14 | var defaultLanguageCode: String { get } 15 | var regionCode: String { get } 16 | } 17 | 18 | extension Locale { 19 | var locale: String { 20 | return languageCode + regionCode 21 | } 22 | 23 | var defaultLanguageCode: String { 24 | let 🏴󠁧󠁢󠁥󠁮󠁧󠁿👅 = "en" 25 | return 🏴󠁧󠁢󠁥󠁮󠁧󠁿👅 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Conjugar/DictionaryGetterSetter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DictionaryGetterSetter.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 1/13/19. 6 | // Copyright © 2019 Josh Adams. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class DictionaryGetterSetter: GetterSetter { 12 | var dictionary: [String: String] = [:] 13 | 14 | init() {} 15 | 16 | init(dictionary: [String: String]) { 17 | self.dictionary = dictionary 18 | } 19 | 20 | func get(key: String) -> String? { 21 | return dictionary[key] 22 | } 23 | 24 | func set(key: String, value: String) { 25 | dictionary[key] = value 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Conjugar/FontExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FontExtensions.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 11/3/19. 6 | // Copyright © 2019 Josh Adams. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | extension Font { 12 | static var heading: Font { 13 | Font.custom("AvenirNext-Bold", size: 24.0) 14 | } 15 | 16 | static var subheading: Font { 17 | Font.custom("AvenirNext-Bold", size: 18.0) 18 | } 19 | 20 | static var smallBody: Font { 21 | Font.custom("AvenirNext-Demibold", size: 12.0) 22 | } 23 | 24 | static var button: Font { 25 | Font.custom("AvenirNext-Demibold", size: 24.0) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /ConjugarTests/Utils/IntExtensionTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IntExtensionTests.swift 3 | // ConjugarTests 4 | // 5 | // Created by Joshua Adams on 4/24/19. 6 | // Copyright © 2019 Josh Adams. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Conjugar 11 | 12 | class IntExtensionTests: XCTestCase { 13 | func testShort() { 14 | let time = 42 15 | XCTAssertEqual(time.timeString, "42") 16 | } 17 | 18 | func testMedium() { 19 | let time = 142 20 | XCTAssertEqual(time.timeString, "2:22") 21 | } 22 | 23 | func testLong() { 24 | let time = 3601 25 | XCTAssertEqual(time.timeString, "1:00:01") 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /ConjugarTests/Utils/UsesAutoLayoutTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UsesAutoLayoutTests.swift 3 | // ConjugarTests 4 | // 5 | // Created by Josh Adams on 1/28/20. 6 | // Copyright © 2020 Josh Adams. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Conjugar 11 | 12 | class UsesAutoLayoutTests: XCTestCase { 13 | @UsesAutoLayout 14 | var wrappedView: UIView = { 15 | return UIView() 16 | }() 17 | 18 | func testUsesAutoLayout() { 19 | let vanillaView = UIView() 20 | XCTAssert(vanillaView.translatesAutoresizingMaskIntoConstraints) 21 | XCTAssertFalse(wrappedView.translatesAutoresizingMaskIntoConstraints) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ConjugarTests/UIViews/TenseCellTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TenseCellTests.swift 3 | // ConjugarTests 4 | // 5 | // Created by Joshua Adams on 5/22/19. 6 | // Copyright © 2019 Josh Adams. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Conjugar 11 | 12 | class TenseCellTests: XCTestCase { 13 | func testTenseCell() { 14 | let cell = TenseCell(style: .default, reuseIdentifier: "cell") 15 | XCTAssertEqual(cell.tense.textColor, Colors.red) 16 | XCTAssertEqual(cell.tense.font, Fonts.regularCell) 17 | 18 | cell.configure(tense: "Presente de Indicativo") 19 | XCTAssertEqual(cell.tense.text, "Presente de Indicativo") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Conjugar/CommunGetter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommunGetter.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 12/14/20. 6 | // Copyright © 2020 Josh Adams. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import MessageUI 11 | 12 | protocol CommunGetter { 13 | func getCommunication(completion: @escaping (Commun) -> Void) 14 | func openUrlClosure(urlString: String) -> (() -> ())? 15 | } 16 | 17 | extension CommunGetter { 18 | func openUrlClosure(urlString: String) -> (() -> ())? { 19 | if let url = URL(string: urlString) { 20 | return { UIApplication.shared.open(url, options: [:], completionHandler: nil) } 21 | } else { 22 | return nil 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /ConjugarTests/Utils/UIViewControllerExtensionsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIViewControllerExtensionsTests.swift 3 | // ConjugarTests 4 | // 5 | // Created by Joshua Adams on 4/24/19. 6 | // Copyright © 2019 Josh Adams. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import UIKit 11 | @testable import Conjugar 12 | 13 | class UIViewControllerExtensionsTests: XCTestCase { 14 | func testFatalCastMessage() { 15 | Current = World.unitTest 16 | let vc = VerbVC(verb: "maltear") 17 | let view = VerbUIV() 18 | let message = vc.fatalCastMessage(view: view.self) 19 | XCTAssert(message.contains("Could not cast UILabel { 13 | let titleLabel = UILabel() 14 | titleLabel.text = title 15 | titleLabel.font = Fonts.heading 16 | titleLabel.textColor = Colors.yellow 17 | let width = titleLabel.sizeThatFits(CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)).width 18 | let tappableHeight: CGFloat = 100.0 19 | titleLabel.frame = CGRect(origin: CGPoint.zero, size: CGSize(width: width, height: tappableHeight)) 20 | return titleLabel 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /ConjugarTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /ConjugarTests/Supporting/TestingAppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestingAppDelegate.swift 3 | // ConjugarTests 4 | // 5 | // Created by Joshua Adams on 1/31/20. 6 | // Copyright © 2020 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | @testable import Conjugar 11 | 12 | @objc(TestingAppDelegate) 13 | final class TestingAppDelegate: UIResponder, UIApplicationDelegate { 14 | var window: UIWindow? 15 | 16 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 17 | Current = World.unitTest 18 | window = UIWindow(frame: UIScreen.main.bounds) 19 | window?.rootViewController = TestingRootViewController() 20 | window?.makeKeyAndVisible() 21 | return true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ConjugarUITests/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 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Conjugar/TestAnalyticsService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestAnalyticsService.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 11/25/18. 6 | // Copyright © 2018 Josh Adams. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class TestAnalyticsService: AnalyticsServiceable { 12 | private var fire: (String) -> () 13 | 14 | init(fire: @escaping (String) -> () = { analytic in print(analytic) }) { 15 | self.fire = fire 16 | } 17 | 18 | func recordEvent(_ eventName: String, parameters: [String: String]?, metrics: [String: Double]?) { 19 | var analytic = eventName 20 | if let parameters = parameters { 21 | analytic += " " 22 | for (key, value) in parameters { 23 | analytic += key + ": " + value + " " 24 | } 25 | } 26 | fire(analytic) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Conjugar/IntExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IntExtension.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 6/25/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension Int { 12 | var timeString: String { 13 | var remainingSeconds = self 14 | let hours = remainingSeconds / 3600 15 | remainingSeconds -= hours * 3600 16 | let minutes = remainingSeconds / 60 17 | remainingSeconds -= minutes * 60 18 | if hours > 0 { 19 | return NSString(format: "%d:%02d:%02d", hours, minutes, remainingSeconds) as String 20 | } else if minutes > 0 { 21 | return NSString(format: "%d:%02d", minutes, remainingSeconds) as String 22 | } else { 23 | return NSString(format: "%d", remainingSeconds) as String 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Conjugar/Difficulty.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Difficulty.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 6/17/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | enum Difficulty: String, CaseIterable { 10 | case easy = "Easy" 11 | case moderate = "Moderate" 12 | case difficult = "Difficult" 13 | 14 | var scoreModifier: Double { 15 | switch self { 16 | case .easy: 17 | return 0.5 18 | case .moderate: 19 | return 1.0 20 | case .difficult: 21 | return 1.5 22 | } 23 | } 24 | 25 | var localizedDifficulty: String { 26 | switch self { 27 | case .easy: 28 | return Localizations.easy 29 | case .moderate: 30 | return Localizations.moderate 31 | case .difficult: 32 | return Localizations.difficult 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Conjugar/URLProtocolStub.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLProtocolStub.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 4/26/19. 6 | // Based on an article by Paul Hudson. 7 | 8 | import Foundation 9 | 10 | class URLProtocolStub: URLProtocol { 11 | static var testURLs = [URL?: Data]() 12 | 13 | override class func canInit(with request: URLRequest) -> Bool { 14 | return true 15 | } 16 | 17 | override class func canonicalRequest(for request: URLRequest) -> URLRequest { 18 | return request 19 | } 20 | 21 | override func startLoading() { 22 | if let url = request.url { 23 | if let data = URLProtocolStub.testURLs[url] { 24 | self.client?.urlProtocol(self, didLoad: data) 25 | } 26 | } 27 | self.client?.urlProtocolDidFinishLoading(self) 28 | } 29 | 30 | override func stopLoading() { } 31 | } 32 | -------------------------------------------------------------------------------- /ConjugarTests/Helpers/UIColorExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIColorExtension.swift 3 | // ConjugarTests 4 | // 5 | // Created by Joshua Adams on 5/22/19. 6 | // Borrowed from: https://stackoverflow.com/a/48610603/8248798 7 | // 8 | 9 | import UIKit 10 | 11 | extension UIColor { 12 | static func == (l: UIColor, r: UIColor) -> Bool { 13 | var l_red = CGFloat(0); var l_green = CGFloat(0); var l_blue = CGFloat(0); var l_alpha = CGFloat(0) 14 | guard l.getRed(&l_red, green: &l_green, blue: &l_blue, alpha: &l_alpha) else { return false } 15 | var r_red = CGFloat(0); var r_green = CGFloat(0); var r_blue = CGFloat(0); var r_alpha = CGFloat(0) 16 | guard r.getRed(&r_red, green: &r_green, blue: &r_blue, alpha: &r_alpha) else { return false } 17 | return l_red == r_red && l_green == r_green && l_blue == r_blue && l_alpha == r_alpha 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Conjugar/Region.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Region.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 3/31/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | enum Region: String, CaseIterable { 10 | case spain = "Spain" 11 | case latinAmerica = "Latin America" 12 | 13 | var accent: String { 14 | switch self { 15 | case .spain: 16 | return "ES" 17 | case .latinAmerica: 18 | return "MX" 19 | } 20 | } 21 | 22 | var scoreModifier: Double { 23 | switch self { 24 | case .spain: 25 | return 1.0 26 | case .latinAmerica: 27 | return 0.833 28 | } 29 | } 30 | 31 | var localizedRegion: String { 32 | switch self { 33 | case .spain: 34 | return Localizations.spain 35 | case .latinAmerica: 36 | return Localizations.latinAmerica 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Conjugar/Commun.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Commun.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 12/14/20. 6 | // Copyright © 2020 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | struct Commun { 12 | let title: [String: String] 13 | let image: UIImage 14 | let imageLabel: [String: String] 15 | let content: [String: String] 16 | let type: CommunType 17 | let identifier: Int 18 | 19 | enum CommunType { 20 | case information(okayTitle: [String: String]) 21 | case newVersion(okayTitle: [String: String], actionTitle: [String: String], cancelTitle: [String: String], action: () -> (), alreadyUpdated: Bool) 22 | case email(actionTitle: [String: String], cancelTitle: [String: String], action: () -> ()) 23 | case website(actionTitle: [String: String], cancelTitle: [String: String], action: () -> ()) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Conjugar/TestGameCenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestGameCenter.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 11/27/18. 6 | // Copyright © 2018 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class TestGameCenter: GameCenterable { 12 | var isAuthenticated: Bool 13 | 14 | init(isAuthenticated: Bool = false) { 15 | self.isAuthenticated = isAuthenticated 16 | } 17 | 18 | func authenticate(onViewController: UIViewController, completion: ((Bool) -> Void)?) { 19 | if !isAuthenticated { 20 | isAuthenticated = true 21 | completion?(true) 22 | } else { 23 | completion?(false) 24 | } 25 | } 26 | 27 | func reportScore(_ score: Int) { 28 | print("Pretending to report score \(score).") 29 | } 30 | 31 | func showLeaderboard() { 32 | print("Pretending to show leaderboard.") 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ConjugarTests/Utils/UIViewExtensionsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIViewExtensionsTests.swift 3 | // ConjugarTests 4 | // 5 | // Created by Joshua Adams on 4/28/19. 6 | // Copyright © 2019 Josh Adams. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Conjugar 11 | 12 | class UIViewExtensionsTests: XCTestCase { 13 | func testPulsate() { 14 | let view = UIView() 15 | view.pulsate() 16 | let expectatiön = expectation(description: "testPulsate") 17 | let duration: TimeInterval = 0.3 18 | let cushion: TimeInterval = 1.0 19 | let timeoutFactor: TimeInterval = 2.0 20 | DispatchQueue.main.asyncAfter(deadline: .now() + duration + cushion, execute: { 21 | XCTAssert(view.transform.isIdentity) 22 | expectatiön.fulfill() 23 | }) 24 | wait(for: [expectatiön], timeout: duration + cushion * timeoutFactor) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /ConjugarTests/Models/ConjugationResultTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConjugationResultTests.swift 3 | // ConjugarTests 4 | // 5 | // Created by Joshua Adams on 5/13/19. 6 | // Copyright © 2019 Josh Adams. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Conjugar 11 | 12 | class ConjugationResultTests: XCTestCase { 13 | func testCompare() { 14 | var lhs = "" 15 | var rhs = "" 16 | 17 | XCTAssertEqual(ConjugationResult.compare(lhs: lhs, rhs: rhs), .noMatch) 18 | lhs = " " 19 | XCTAssertEqual(ConjugationResult.compare(lhs: lhs, rhs: rhs), .noMatch) 20 | lhs = "cómo" 21 | XCTAssertEqual(ConjugationResult.compare(lhs: lhs, rhs: rhs), .noMatch) 22 | rhs = "comó" 23 | XCTAssertEqual(ConjugationResult.compare(lhs: lhs, rhs: rhs), .partialMatch) 24 | lhs = "comó" 25 | XCTAssertEqual(ConjugationResult.compare(lhs: lhs, rhs: rhs), .totalMatch) 26 | rhs = "🥥🥥🥥🥥" 27 | XCTAssertEqual(ConjugationResult.compare(lhs: lhs, rhs: rhs), .noMatch) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Conjugar/UIAlertControllerExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIAlertControllerExtension.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 8/19/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UIAlertController { 12 | // See http://stackoverflow.com/a/39975404/2084036 for why this needs to be a class method rather than a class property. 13 | static func okTitle() -> String { return Localizations.okay } 14 | 15 | class func showMessage(_ message: String, title: String, okTitle: String, onViewController viewController: UIViewController, handler: ((UIAlertAction) -> Void)? = nil) { 16 | let alertController = UIAlertController(title: title, message: message, preferredStyle: UIAlertController.Style.alert) 17 | let okAction = UIAlertAction(title: okTitle, style: UIAlertAction.Style.default, handler: handler) 18 | alertController.addAction(okAction) 19 | viewController.present(alertController, animated: true, completion: nil) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Conjugar/ConjugationResult.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConjugationResult.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 6/20/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | enum ConjugationResult: Int { 10 | case totalMatch = 10 11 | case partialMatch = 5 12 | case noMatch = 0 13 | 14 | static func compare(lhs: String, rhs: String) -> ConjugationResult { 15 | let lhsCount = lhs.count 16 | let rhsCount = rhs.count 17 | if lhsCount != rhsCount || lhsCount == 0 { 18 | return .noMatch 19 | } 20 | var lhsClean = lhs.lowercased() 21 | var rhsClean = rhs.lowercased() 22 | if lhsClean == rhsClean { 23 | return .totalMatch 24 | } 25 | [("á", "a"), ("é", "e"), ("í", "i"), ("ó", "o"), ("ú", "u")].forEach { 26 | lhsClean = lhsClean.replacingOccurrences(of: $0.0, with: $0.1) 27 | rhsClean = rhsClean.replacingOccurrences(of: $0.0, with: $0.1) 28 | } 29 | if lhsClean == rhsClean { 30 | return .partialMatch 31 | } else { 32 | return .noMatch 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Conjugar/Emailer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Emailer.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 12/20/20. 6 | // Copyright © 2020 Josh Adams. All rights reserved. 7 | // 8 | 9 | import MessageUI 10 | 11 | struct Emailer { 12 | static let shared = Emailer() 13 | private let delegāte = EmailDelegate() 14 | 15 | var sendEmailClosure: (() -> ())? { 16 | if MFMailComposeViewController.canSendMail() { 17 | return { 18 | let mail = MFMailComposeViewController() 19 | mail.mailComposeDelegate = delegāte 20 | mail.setToRecipients(["vermontcoder@gmail.com"]) 21 | mail.setSubject("Conjugar") 22 | UIApplication.topViewController()?.present(mail, animated: true) 23 | } 24 | } else { 25 | return nil 26 | } 27 | } 28 | 29 | private class EmailDelegate: NSObject, MFMailComposeViewControllerDelegate { 30 | func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) { 31 | controller.dismiss(animated: true) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Conjugar/InfoCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InfoCell.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 7/1/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class InfoCell: UITableViewCell { 12 | static let identifier = "InfoCell" 13 | 14 | @UsesAutoLayout 15 | var heading: UILabel = { 16 | let label = UILabel() 17 | label.textColor = Colors.yellow 18 | label.font = Fonts.boldBody 19 | return label 20 | }() 21 | 22 | required init?(coder aDecoder: NSCoder) { 23 | NSCoder.fatalErrorNotImplemented() 24 | } 25 | 26 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 27 | super.init(style: style, reuseIdentifier: reuseIdentifier) 28 | backgroundColor = Colors.black 29 | addSubview(heading) 30 | 31 | NSLayoutConstraint.activate([ 32 | heading.centerXAnchor.constraint(equalTo: centerXAnchor), 33 | heading.centerYAnchor.constraint(equalTo: centerYAnchor) 34 | ]) 35 | } 36 | 37 | func configure(heading: String) { 38 | self.heading.text = heading 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /ConjugarTests/Utils/TestGameCenterTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestGameCenterTests.swift 3 | // ConjugarTests 4 | // 5 | // Created by Joshua Adams on 4/24/19. 6 | // Copyright © 2019 Josh Adams. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Conjugar 11 | 12 | class TestGameCenterTests: XCTestCase { 13 | func testAuthenticate() { 14 | let tgc = TestGameCenter() 15 | Current = World.unitTest 16 | Current.gameCenter = tgc 17 | let dummyVC = UIViewController() 18 | 19 | tgc.authenticate(onViewController: dummyVC, completion: { didAuthenticate in 20 | XCTAssert(didAuthenticate) 21 | 22 | tgc.authenticate(onViewController: dummyVC, completion: { didAuthenticate in 23 | XCTAssertFalse(didAuthenticate) 24 | }) 25 | }) 26 | } 27 | 28 | func testReportScore() { 29 | // Nothing to test. Exercising for coverage. 30 | let tgc = TestGameCenter() 31 | tgc.reportScore(42) 32 | } 33 | 34 | func testShowLeaderboard() { 35 | // Nothing to test. Exercising for coverage. 36 | let tgc = TestGameCenter() 37 | tgc.showLeaderboard() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Conjugar/Fonts.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Fonts.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 7/2/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | struct Fonts { 12 | static let heading = UIFont(name: "AvenirNext-Bold", size: 24.0) ?? safeFont 13 | static let subheading = UIFont(name: "AvenirNext-Demibold", size: 18.0) ?? safeFont 14 | static let largeCell = UIFont(name: "AvenirNext-Regular", size: 24.0) ?? safeFont 15 | static let regularCell = UIFont(name: "AvenirNext-Bold", size: 18.0) ?? safeFont 16 | static let smallCell = UIFont(name: "AvenirNext-Regular", size: 18.0) ?? safeFont 17 | static let button = UIFont(name: "AvenirNext-Demibold", size: 24.0) ?? safeFont 18 | static let label = UIFont(name: "AvenirNext-Demibold", size: 18.0) ?? safeFont 19 | static let body = UIFont(name: "AvenirNext-Regular", size: 16.0) ?? safeFont 20 | static let smallBody = UIFont(name: "AvenirNext-Demibold", size: 12.0) ?? safeFont 21 | static let boldBody = UIFont(name: "AvenirNext-Bold", size: 16.0) ?? safeFont 22 | private static let safeFont = UIFont.systemFont(ofSize: 18.0) 23 | } 24 | -------------------------------------------------------------------------------- /Conjugar/InfoUIV.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InfoUIV.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 7/30/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class InfoUIV: UIView { 12 | @UsesAutoLayout 13 | var info: UITextView = { 14 | var textView = UITextView() 15 | textView.backgroundColor = Colors.black 16 | textView.textColor = Colors.yellow 17 | textView.tintColor = Colors.blue 18 | textView.isEditable = false 19 | return textView 20 | }() 21 | 22 | override init(frame: CGRect) { 23 | super.init(frame: frame) 24 | addSubview(info) 25 | 26 | NSLayoutConstraint.activate([ 27 | info.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), 28 | info.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), 29 | info.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), 30 | info.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -1.0 * Layout.defaultSpacing) 31 | ]) 32 | } 33 | 34 | required init(coder aDecoder: NSCoder) { 35 | NSCoder.fatalErrorNotImplemented() 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /ConjugarTests/Utils/UIAlertControllerExtensionTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIAlertControllerExtensionTests.swift 3 | // ConjugarTests 4 | // 5 | // Created by Joshua Adams on 5/3/19. 6 | // Copyright © 2019 Josh Adams. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import UIKit 11 | @testable import Conjugar 12 | 13 | class UIAlertControllerExtensionTests: XCTestCase, InfoDelegate { 14 | func testShowMessage() { 15 | Current = World.unitTest 16 | let ivc = InfoVC(infoString: NSAttributedString(string: "🍕"), infoDelegate: self) 17 | 18 | guard let window = UIApplication.shared.connectedScenes 19 | .filter({$0.activationState == .foregroundActive}) 20 | .map({$0 as? UIWindowScene}) 21 | .compactMap({$0}) 22 | .first?.windows 23 | .filter({$0.isKeyWindow}).first else { 24 | XCTFail("Could not create window.") 25 | return 26 | } 27 | 28 | window.rootViewController = ivc 29 | 30 | ivc.viewWillAppear(true) 31 | UIAlertController.showMessage("", title: "", okTitle: "", onViewController: ivc) 32 | XCTAssert(ivc.presentedViewController is UIAlertController) 33 | } 34 | 35 | func infoSelectionDidChange(newHeading: String) { } 36 | } 37 | -------------------------------------------------------------------------------- /Conjugar/UIApplicationExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIApplicationExtensions.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 12/17/20. 6 | // Copyright © 2020 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UIApplication { 12 | class func topViewController(_ base: UIViewController? = UIApplication.shared.compatibilityWindow?.rootViewController) -> UIViewController? { 13 | if let nav = base as? UINavigationController { 14 | return topViewController(nav.visibleViewController) 15 | } 16 | if let tab = base as? UITabBarController { 17 | if let selected = tab.selectedViewController { 18 | return topViewController(selected) 19 | } 20 | } 21 | if let presented = base?.presentedViewController { 22 | return topViewController(presented) 23 | } 24 | return base 25 | } 26 | 27 | // https://stackoverflow.com/a/57169802/8248798 28 | var compatibilityWindow: UIWindow? { 29 | return UIApplication.shared.connectedScenes 30 | .filter({$0.activationState == .foregroundActive}) 31 | .map({$0 as? UIWindowScene}) 32 | .compactMap({$0}) 33 | .first?.windows 34 | .filter({$0.isKeyWindow}).first 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /ConjugarTests/UIViews/ConjugationCellTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConjugationCellTests.swift 3 | // ConjugarTests 4 | // 5 | // Created by Joshua Adams on 5/22/19. 6 | // Copyright © 2019 Josh Adams. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Conjugar 11 | 12 | class ConjugationCellTests: XCTestCase { 13 | func testConjugationCell() { 14 | let cell = ConjugationCell(style: .default, reuseIdentifier: "cell") 15 | XCTAssertEqual(cell.conjugation.textColor, Colors.yellow) 16 | XCTAssertEqual(cell.conjugation.font, Fonts.smallCell) 17 | 18 | cell.configure(tense: .pretérito, personNumber: .secondSingularTú, conjugation: "fuistes") 19 | XCTAssertEqual(cell.conjugation.text, "tú fuistes") 20 | 21 | cell.configure(tense: .imperativoPositivo, personNumber: .secondSingularTú, conjugation: "se") 22 | XCTAssertEqual(cell.conjugation.text, "¡se!") 23 | 24 | cell.configure(tense: .imperativoNegativo, personNumber: .secondSingularTú, conjugation: "no seas") 25 | XCTAssertEqual(cell.conjugation.text, "¡no seas!") 26 | 27 | cell.configure(tense: .imperativoNegativo, personNumber: .secondSingularTú, conjugation: Conjugator.defective) 28 | XCTAssertEqual(cell.conjugation.text, "") 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Conjugar/UIViewExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIViewExtension.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 8/12/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UIView { 12 | func pulsate() { 13 | let duration: TimeInterval = 0.3 14 | let scale: CGFloat = 0.6 15 | UIView.animate(withDuration: duration, animations: { 16 | self.transform = CGAffineTransform.identity.scaledBy(x: scale, y: scale) 17 | }, completion: { _ in 18 | UIView.animate(withDuration: duration, animations: { 19 | self.transform = CGAffineTransform.identity 20 | }) 21 | }) 22 | } 23 | } 24 | 25 | extension UIView { 26 | func setAccessibilityLabelInSpanish(_ label: String, region: String = Current.settings.region.accent) { 27 | setAccessibilityLabel(label, spokenInLanguage: "es_\(region)") 28 | } 29 | 30 | private func setAccessibilityLabel(_ label: String, spokenInLanguage languageCode: String) { 31 | let attributes: [NSAttributedString.Key: Any] = [ 32 | .accessibilitySpeechLanguage: languageCode 33 | ] 34 | let attributedLabel = NSAttributedString(string: label, attributes: attributes) 35 | accessibilityAttributedLabel = attributedLabel 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /privacyPolicy.md: -------------------------------------------------------------------------------- 1 | Privacy Policy 2 | =================== 3 | 4 | Conjugar uses the Amazon Web Services™ analytics product, Pinpoint™, to track certain information. This information consists of: 5 | 6 | * Each screen visited 7 | * The act of starting a quiz 8 | * The act of finishing a quiz, with score 9 | * Game Center™ authentication 10 | * App version 11 | * Unique installs 12 | * Device type (iPhone or iPad) 13 | * Country of user 14 | 15 | Conjugar does _not_ track any personally identifiable user information. 16 | 17 | As demonstrated in the following examples, analytics inform future development of Conjugar. 18 | 19 | At present, quizzes have fifty verbs. The goal of this length is to present a mix of all tenses for the current difficulty level with a mix of regular and irregular verbs. But if, for example, most users are starting but not finishing quizzes, Conjugar's developer might consider shortening the quiz length. 20 | 21 | Conjugar has Game Center™ integration. Use of this integration is optional. If most users are not authenticating Game Center™, Conjugar's developer might consider removing the integration. 22 | 23 | Please direct any questions about or feedback on this privacy policy to Conjugar's [developer](mailto:vermontcoder@gmail.com). 24 | -------------------------------------------------------------------------------- /ConjugarTests/UIViews/ResultCellTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ResultCellTests.swift 3 | // ConjugarTests 4 | // 5 | // Created by Joshua Adams on 5/16/19. 6 | // Copyright © 2019 Josh Adams. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Conjugar 11 | 12 | class ResultCellTests: XCTestCase { 13 | func testResultCell() { 14 | let cell = ResultCell(style: .default, reuseIdentifier: "cell") 15 | 16 | cell.configure(verb: "dar", tense: .presenteDeIndicativo, personNumber: .firstSingular, correctAnswer: "doy", proposedAnswer: "doy") 17 | XCTAssertEqual(cell.verb.text, "dar") 18 | XCTAssertEqual(cell.tensePersonNumber.text, "presente de indicativo, 1S") 19 | XCTAssertEqual(cell.correctAnswer.text, "doy") 20 | XCTAssertEqual(cell.proposedAnswer.text, "doy") 21 | XCTAssert(cell.proposedAnswer.textColor == Colors.yellow) 22 | 23 | cell.configure(verb: "dar", tense: .presenteDeIndicativo, personNumber: .firstSingular, correctAnswer: "doy", proposedAnswer: "do") 24 | XCTAssertEqual(cell.verb.text, "dar") 25 | XCTAssertEqual(cell.tensePersonNumber.text, "presente de indicativo, 1S") 26 | XCTAssertEqual(cell.correctAnswer.text, "doy") 27 | XCTAssertEqual(cell.proposedAnswer.text, "do") 28 | XCTAssert(cell.proposedAnswer.textColor == Colors.blue) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Conjugar/Modifiers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Modifiers.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 11/3/19. 6 | // Copyright © 2019 Josh Adams. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct HeadingLabel: ViewModifier { 12 | func body(content: Content) -> some View { 13 | content 14 | .font(.heading) 15 | .foregroundColor(Color(Colors.yellow)) 16 | } 17 | } 18 | 19 | struct SubheadingLabel: ViewModifier { 20 | func body(content: Content) -> some View { 21 | content 22 | .font(.subheading) 23 | .foregroundColor(Color(Colors.yellow)) 24 | } 25 | } 26 | 27 | struct BodyLabel: ViewModifier { 28 | func body(content: Content) -> some View { 29 | content 30 | .font(.smallBody) 31 | .foregroundColor(Color(Colors.yellow)) 32 | .padding(.horizontal, Layout.defaultHorizontalMargin) 33 | } 34 | } 35 | 36 | struct StandardButton: ViewModifier { 37 | func body(content: Content) -> some View { 38 | content 39 | .font(.button) 40 | .foregroundColor(Color(Colors.red)) 41 | } 42 | } 43 | 44 | struct SegmentedPicker: ViewModifier { 45 | func body(content: Content) -> some View { 46 | content 47 | .pickerStyle(SegmentedPickerStyle()) 48 | .padding(.horizontal, Layout.defaultHorizontalMargin) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Conjugar/VerbCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VerbCell.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 4/10/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class VerbCell: UITableViewCell { 12 | static let identifier = "VerbCell" 13 | 14 | @UsesAutoLayout 15 | var verb: UILabel = { 16 | let label = UILabel() 17 | label.textColor = Colors.yellow 18 | label.font = Fonts.largeCell 19 | label.textAlignment = .center 20 | label.adjustsFontSizeToFitWidth = true 21 | return label 22 | }() 23 | 24 | required init?(coder aDecoder: NSCoder) { 25 | NSCoder.fatalErrorNotImplemented() 26 | } 27 | 28 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 29 | super.init(style: style, reuseIdentifier: reuseIdentifier) 30 | backgroundColor = Colors.black 31 | addSubview(verb) 32 | 33 | NSLayoutConstraint.activate([ 34 | verb.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.defaultSpacing), 35 | verb.trailingAnchor.constraint(equalTo: trailingAnchor, constant: Layout.defaultSpacing * -1.0), 36 | verb.centerYAnchor.constraint(equalTo: centerYAnchor) 37 | ]) 38 | } 39 | 40 | func configure(verb: String) { 41 | self.verb.text = verb 42 | self.verb.setAccessibilityLabelInSpanish(verb) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /ConjugarTests/Utils/RatingsFetcherTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RatingsFetcherTests.swift 3 | // ConjugarTests 4 | // 5 | // Created by Joshua Adams on 4/26/19. 6 | // Copyright © 2019 Josh Adams. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Conjugar 11 | 12 | class RatingsFetcherTests: XCTestCase { 13 | override func setUp() { 14 | Current = World.unitTest 15 | } 16 | 17 | func testNoReviews() { 18 | testDescription(count: 0, expectedDescription: "No one has rated this version of Conjugar. ¡Sé la primera o el primero!") 19 | } 20 | 21 | func testOneReview() { 22 | testDescription(count: 1, expectedDescription: "There is one rating for this version of Conjugar. Add yours!") 23 | } 24 | 25 | func testManyReviews() { 26 | testDescription(count: 42, expectedDescription: "There are 42 ratings for this version of Conjugar. Add yours!") 27 | } 28 | 29 | private func testDescription(count: Int, expectedDescription: String) { 30 | Current.session = URLSession.stubSession(ratingsCount: count) 31 | let expectatiön = expectation(description: "testDescription") 32 | RatingsFetcher.fetchRatingsDescription { actualDescription in 33 | XCTAssertEqual(actualDescription, expectedDescription) 34 | expectatiön.fulfill() 35 | } 36 | let timeout: TimeInterval = 0.5 37 | wait(for: [expectatiön], timeout: timeout) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Conjugar/SoundPlayer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SoundPlayer.swift 3 | // Conjugar 4 | // 5 | // Created by Josh Adams on 11/18/15. 6 | // Copyright © 2015 Josh Adams. All rights reserved. 7 | // 8 | 9 | import AVFoundation 10 | 11 | class SoundPlayer { 12 | private static let soundPlayer = SoundPlayer() 13 | private var sounds: [String: AVAudioPlayer] 14 | private static let soundExtension = "mp3" 15 | 16 | private init () { 17 | sounds = Dictionary() 18 | do { 19 | try AVAudioSession.sharedInstance().setCategory(.playback) // was ambient 20 | } catch let error as NSError { 21 | print("\(error.localizedDescription)") 22 | } 23 | } 24 | 25 | static func play(_ sound: Sound) { 26 | if soundPlayer.sounds[sound.rawValue] == nil { 27 | if let audioUrl = Bundle.main.url(forResource: sound.rawValue, withExtension: soundExtension) { 28 | do { 29 | try soundPlayer.sounds[sound.rawValue] = AVAudioPlayer.init(contentsOf: audioUrl) 30 | } catch let error as NSError { 31 | print("\(error.localizedDescription)") 32 | } 33 | } 34 | } 35 | soundPlayer.sounds[sound.rawValue]?.play() 36 | } 37 | 38 | static func playRandomApplause() { 39 | let applauses: [Sound] = [.applause1, .applause2, .applause3] 40 | let applauseIndex = Int.random(in: 0 ... (applauses.count - 1)) 41 | SoundPlayer.play(applauses[applauseIndex]) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Conjugar/TenseCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TenseCell.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 5/7/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class TenseCell: UITableViewCell { 12 | static let identifier = "TenseCell" 13 | 14 | @UsesAutoLayout 15 | var tense: UILabel = { 16 | let label = UILabel() 17 | label.textColor = Colors.red 18 | label.font = Fonts.regularCell 19 | label.textAlignment = .center 20 | label.adjustsFontSizeToFitWidth = true 21 | return label 22 | }() 23 | 24 | required init?(coder aDecoder: NSCoder) { 25 | NSCoder.fatalErrorNotImplemented() 26 | } 27 | 28 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 29 | super.init(style: style, reuseIdentifier: reuseIdentifier) 30 | backgroundColor = Colors.black 31 | selectionStyle = .none 32 | addSubview(tense) 33 | accessibilityTraits = accessibilityTraits.union(.header) 34 | 35 | NSLayoutConstraint.activate([ 36 | tense.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.defaultSpacing), 37 | tense.trailingAnchor.constraint(equalTo: trailingAnchor, constant: Layout.defaultSpacing * -1.0), 38 | tense.centerYAnchor.constraint(equalTo: centerYAnchor) 39 | ]) 40 | } 41 | 42 | func configure(tense: String) { 43 | self.tense.text = tense 44 | self.tense.setAccessibilityLabelInSpanish(tense) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Conjugar/Utterer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Utterer.swift 3 | // Conjugar 4 | // 5 | // Created by Josh Adams on 11/18/15. 6 | // Copyright © 2015 Josh Adams. All rights reserved. 7 | // 8 | 9 | import AVFoundation 10 | 11 | class Utterer { 12 | private static let synth = AVSpeechSynthesizer() 13 | private static let rate: Float = 0.5 14 | private static let pitchMultiplier: Float = 0.8 15 | private static var settings: Settings? 16 | 17 | static func setup(settings: Settings) { 18 | Utterer.settings = settings 19 | 20 | let session = AVAudioSession.sharedInstance() 21 | do { 22 | try session.setCategory(.playback, options: .mixWithOthers) 23 | } catch let error as NSError { 24 | print("\(error.localizedDescription)") 25 | } 26 | utter("") 27 | } 28 | 29 | static func utter(_ thingToUtter: String, locale: String? = nil) { 30 | guard let settings = settings else { 31 | fatalError("settings not initialized. Accent not inferrable.") 32 | } 33 | let utterance = AVSpeechUtterance(string: thingToUtter) 34 | utterance.rate = Utterer.rate 35 | if let locale = locale { 36 | utterance.voice = AVSpeechSynthesisVoice(language: locale) 37 | } else { 38 | utterance.voice = AVSpeechSynthesisVoice(language: "es-" + settings.region.accent) 39 | } 40 | utterance.pitchMultiplier = Utterer.pitchMultiplier 41 | synth.speak(utterance) 42 | SoundPlayer.play(.silence) // https://forums.developer.apple.com/thread/23160 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Conjugar/ReviewPrompter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReviewPrompter.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 1/5/18. 6 | // Copyright © 2018 Josh Adams. All rights reserved. 7 | // 8 | 9 | import StoreKit 10 | 11 | struct ReviewPrompter: ReviewPromptable { 12 | static let promptModulo = 9 13 | static let promptInterval: TimeInterval = 60 * 60 * 24 * 180 14 | private let settings: Settings 15 | private let now: Date 16 | private let requestReview: () -> () 17 | private static let defaultRequestReview: () -> Void = { 18 | if let scene = UIApplication.shared.connectedScenes.first( 19 | where: { $0.activationState == .foregroundActive } 20 | ) as? UIWindowScene { 21 | DispatchQueue.main.async { 22 | SKStoreReviewController.requestReview(in: scene) 23 | } 24 | } 25 | } 26 | 27 | init(settings: Settings = Settings(getterSetter: UserDefaultsGetterSetter()), now: Date = Date(), requestReview: @escaping () -> () = ReviewPrompter.defaultRequestReview) { 28 | self.settings = settings 29 | self.now = now 30 | self.requestReview = requestReview 31 | } 32 | 33 | func promptableActionHappened() { 34 | var actionCount = settings.promptActionCount 35 | actionCount += 1 36 | settings.promptActionCount = actionCount 37 | let lastReviewPromptDate = settings.lastReviewPromptDate 38 | if actionCount % ReviewPrompter.promptModulo == 0 && now.timeIntervalSince(lastReviewPromptDate) >= ReviewPrompter.promptInterval { 39 | requestReview() 40 | settings.lastReviewPromptDate = now 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /ConjugarTests/Controllers/VerbVCTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VerbVCTests.swift 3 | // ConjugarTests 4 | // 5 | // Created by Joshua Adams on 9/2/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Conjugar 11 | 12 | class VerbVCTests: XCTestCase { 13 | func testVerbVC() { 14 | var analytic = "" 15 | Current = World.unitTest 16 | Current.analytics = TestAnalyticsService(fire: { fired in analytic = fired }) 17 | 18 | let vvc = VerbVC(verb: "maltear") 19 | 20 | XCTAssertNotNil(vvc) 21 | XCTAssertNotNil(vvc.verbView) 22 | vvc.viewWillAppear(true) 23 | XCTAssertEqual(analytic, "visited viewController: \(VerbVC.self) ") 24 | } 25 | 26 | func testVerbTypes() { 27 | let arText = "Regular AR" 28 | let erText = "Regular ER" 29 | let irText = "Regular IR" 30 | let defectiveText = "Defective" 31 | let notDefectiveText = "Not Defective" 32 | let parentText = "Irreg. ☛ conocer" 33 | let irregularText = "Irregular" 34 | 35 | [ 36 | ("maltear", arText, notDefectiveText), 37 | ("comer", erText, notDefectiveText), 38 | ("subir", irText, notDefectiveText), 39 | ("gustar", arText, defectiveText), 40 | ("reconocer", parentText, notDefectiveText), 41 | ("ser", irregularText, notDefectiveText) 42 | ].forEach { 43 | let vvc = VerbVC(verb: $0.0) 44 | vvc.viewWillAppear(true) 45 | let vv = vvc.verbView 46 | XCTAssertEqual(vv.parentOrType.text, $0.1) 47 | XCTAssertEqual(vv.defectuoso.text, $0.2) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /privacyPolicy2.html: -------------------------------------------------------------------------------- 1 |

Privacy Policy

2 | 3 |

Conjugar uses the Amazon Web Services™ analytics product, Pinpoint™, to track certain information. This information consists of:

4 | 5 | 28 | 29 |

Conjugar does not track any personally identifiable user information.

30 | 31 |

As demonstrated in the following examples, analytics inform future development of Conjugar.

32 | 33 |

At present, quizzes have fifty verbs. The goal of this length is to present a mix of all tenses for the current difficulty level with a mix of regular and irregular verbs. But if, for example, most users are starting but not finishing quizzes, Conjugar's developer might consider shortening the quiz length.

34 | 35 |

Conjugar has Game Center™ integration. Use of this integration is optional. If most users are not authenticating Game Center™, Conjugar's developer might consider removing the integration.

36 | 37 |

Please direct any questions about or feedback on this privacy policy to Conjugar's developer.

38 | -------------------------------------------------------------------------------- /ConjugarTests/Controllers/BrowseVerbsVCTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BrowseVerbsVCTests.swift 3 | // ConjugarTests 4 | // 5 | // Created by Joshua Adams on 8/27/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Conjugar 11 | 12 | class BrowseVerbsVCTests: XCTestCase { 13 | func testBrowseVerbsVC() { 14 | var analytic = "" 15 | Current.analytics = TestAnalyticsService(fire: { fired in analytic = fired }) 16 | Current.settings = Settings(getterSetter: DictionaryGetterSetter()) 17 | 18 | let bvvc = BrowseVerbsVC() 19 | 20 | let nc = MockNavigationC(rootViewController: bvvc) 21 | 22 | XCTAssertNotNil(bvvc) 23 | bvvc.viewWillAppear(true) 24 | XCTAssertEqual(analytic, "visited viewController: \(BrowseVerbsVC.self) ") 25 | 26 | let irregularVerbCount = Conjugator.shared.irregularVerbs.count 27 | let regularVerbCount = Conjugator.shared.regularVerbs.count 28 | let combinedVerbCount = irregularVerbCount + regularVerbCount 29 | 30 | let bvv = bvvc.browseVerbsView 31 | [(0, irregularVerbCount), (1, regularVerbCount), (2, combinedVerbCount)].forEach { 32 | bvv.filterControl.selectedSegmentIndex = $0.0 33 | XCTAssertEqual(bvvc.tableView(UITableView(), numberOfRowsInSection: 0), $0.1) 34 | } 35 | 36 | bvv.filterControl.selectedSegmentIndex = 0 37 | bvvc.valueChanged(bvv.filterControl) 38 | XCTAssertEqual(bvvc.tableView(UITableView(), numberOfRowsInSection: 0), irregularVerbCount) 39 | 40 | bvvc.tableView(UITableView(), didSelectRowAt: IndexPath(row: 0, section: 0)) 41 | XCTAssert(nc.pushedViewController is VerbVC) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Conjugar/PersonNumber.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PersonNumber.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 3/31/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | enum PersonNumber: String, CaseIterable { 10 | case firstSingular = "fs" 11 | case firstPlural = "fp" 12 | case secondSingularTú = "ss" 13 | case secondSingularVos = "sv" 14 | case secondPlural = "sp" 15 | case thirdSingular = "ts" 16 | case thirdPlural = "tp" 17 | case none = "no" 18 | 19 | var pronoun: String { 20 | switch self { 21 | case .firstSingular: 22 | return "yo" 23 | case .secondSingularTú: 24 | return "tú" 25 | case .secondSingularVos: 26 | return "vos" 27 | case .thirdSingular: 28 | return "él" 29 | case .firstPlural: 30 | return "nosotros" 31 | case .secondPlural: 32 | return "vosotros" 33 | case .thirdPlural: 34 | return "ellas" 35 | case .none: 36 | return "none" 37 | } 38 | } 39 | 40 | var shortDisplayName: String { 41 | switch self { 42 | case .firstSingular: 43 | return "1S" 44 | case .secondSingularTú: 45 | return "2S" 46 | case .secondSingularVos: 47 | return "2SV" 48 | case .thirdSingular: 49 | return "3S" 50 | case .firstPlural: 51 | return "1P" 52 | case .secondPlural: 53 | return "2P" 54 | case .thirdPlural: 55 | return "3P" 56 | case .none: 57 | return "none" 58 | } 59 | } 60 | 61 | static let actualPersonNumbers: [PersonNumber] = [.firstSingular, .secondSingularTú, .secondSingularVos, .thirdSingular, .firstPlural, .secondPlural, .thirdPlural] 62 | } 63 | -------------------------------------------------------------------------------- /Conjugar/AWSAnalyticsService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AWSAnalyticsService.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 10/8/18. 6 | // Copyright © 2018 Joshua Adams. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AWSPinpoint 11 | 12 | class AWSAnalyticsService: NSObject, AnalyticsServiceable { 13 | var pinpoint: AWSPinpoint 14 | 15 | override init() { 16 | let config = AWSPinpointConfiguration.defaultPinpointConfiguration(launchOptions: nil) 17 | pinpoint = AWSPinpoint(configuration: config) 18 | super.init() 19 | recordCustomProfileDemographics() 20 | // AWSDDLog.sharedInstance.logLevel = .verbose 21 | // AWSDDLog.add(AWSDDTTYLogger.sharedInstance) 22 | } 23 | 24 | func recordEvent(_ eventName: String, parameters: [String: String]? = nil, metrics: [String: Double]? = nil) { 25 | let event = pinpoint.analyticsClient.createEvent(withEventType: eventName) 26 | if let parameters = parameters { 27 | for (key, value) in parameters { 28 | event.addAttribute(value, forKey: key) 29 | } 30 | } 31 | if let metrics = metrics { 32 | for (key, value) in metrics { 33 | event.addMetric(NSNumber(value: value), forKey: key) 34 | } 35 | } 36 | pinpoint.analyticsClient.record(event) 37 | pinpoint.analyticsClient.submitEvents() 38 | } 39 | 40 | private func recordCustomProfileDemographics() { 41 | let profile: AWSPinpointEndpointProfile = (pinpoint.targetingClient.currentEndpointProfile()) 42 | profile.demographic?.model = UIDevice.current.modelName 43 | profile.demographic?.platformVersion = UIDevice.current.systemVersion 44 | pinpoint.targetingClient.update(profile) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Conjugar/InfoVC.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InfoVC.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 7/1/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class InfoVC: UIViewController, UITextViewDelegate { 12 | private weak var infoDelegate: InfoDelegate? 13 | private let infoString: NSAttributedString 14 | 15 | var infoView: InfoUIV { 16 | if let castedView = view as? InfoUIV { 17 | return castedView 18 | } else { 19 | fatalError(fatalCastMessage(view: InfoUIV.self)) 20 | } 21 | } 22 | 23 | init(infoString: NSAttributedString, infoDelegate: InfoDelegate) { 24 | self.infoString = infoString 25 | self.infoDelegate = infoDelegate 26 | super.init(nibName: nil, bundle: nil) 27 | } 28 | 29 | required init?(coder aDecoder: NSCoder) { 30 | NSCoder.fatalErrorNotImplemented() 31 | } 32 | 33 | override func loadView() { 34 | let infoView: InfoUIV 35 | infoView = InfoUIV(frame: UIScreen.main.bounds) 36 | infoView.info.attributedText = infoString 37 | infoView.info.delegate = self 38 | infoView.info.contentOffset = CGPoint.zero 39 | view = infoView 40 | } 41 | 42 | override func viewWillAppear(_ animated: Bool) { 43 | super.viewWillAppear(animated) 44 | Current.analytics.recordVisitation(viewController: "\(InfoVC.self)") 45 | } 46 | 47 | func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange) -> Bool { 48 | let http = "http" 49 | if URL.absoluteString.prefix(http.count) == http { 50 | return true 51 | } else { 52 | navigationController?.popViewController(animated: true) 53 | infoDelegate?.infoSelectionDidChange(newHeading: URL.absoluteString) 54 | return false 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /ConjugarTests/Controllers/InfoVCTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InfoVCTests.swift 3 | // ConjugarTests 4 | // 5 | // Created by Joshua Adams on 5/23/19. 6 | // Copyright © 2019 Josh Adams. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import UIKit 11 | @testable import Conjugar 12 | 13 | class InfoVCTests: XCTestCase, InfoDelegate { 14 | var newHeading = "" 15 | 16 | func infoSelectionDidChange(newHeading: String) { 17 | self.newHeading = newHeading 18 | } 19 | 20 | func testInfoVC() { 21 | var analytic = "" 22 | Current = World.unitTest 23 | Current.analytics = TestAnalyticsService(fire: { fired in analytic = fired }) 24 | 25 | let urlString = "https://racecondition.software" 26 | guard let url = URL(string: urlString) else { 27 | XCTFail("Could not create URL.") 28 | return 29 | } 30 | 31 | let nonURLInfoString = "info" 32 | guard let nonURLInfoURL = URL(string: nonURLInfoString) else { 33 | XCTFail("Could not create nonURLInfoURL.") 34 | return 35 | } 36 | 37 | let ivc = InfoVC(infoString: NSAttributedString(string: "\(nonURLInfoString)\(url)"), infoDelegate: self) 38 | 39 | XCTAssertNotNil(ivc) 40 | XCTAssertNotNil(ivc.infoView) 41 | ivc.viewWillAppear(true) 42 | XCTAssertEqual(analytic, "visited viewController: \(InfoVC.self) ") 43 | 44 | XCTAssertEqual(newHeading, "") 45 | var shouldInteract = ivc.textView(UITextView(), shouldInteractWith: nonURLInfoURL, in: NSRange(location: 0, length: nonURLInfoString.count)) 46 | XCTAssertFalse(shouldInteract) 47 | XCTAssertEqual(newHeading, nonURLInfoString) 48 | 49 | shouldInteract = ivc.textView(UITextView(), shouldInteractWith: url, in: NSRange(location: nonURLInfoString.count, length: "\(url)".count)) 50 | XCTAssert(shouldInteract) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /ConjugarTests/Controllers/CommunViewModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommunViewModelTests.swift 3 | // ConjugarTests 4 | // 5 | // Created by Joshua Adams on 12/21/20. 6 | // Copyright © 2020 Josh Adams. All rights reserved. 7 | // 8 | 9 | @testable import Conjugar 10 | import XCTest 11 | 12 | class CommunViewModelTests: XCTestCase { 13 | func testProperties() { 14 | var didTapAction = false 15 | 16 | let settings = Settings(getterSetter: DictionaryGetterSetter()) 17 | let gameCenter = TestGameCenter(isAuthenticated: false) 18 | let analytics = TestAnalyticsService() 19 | let quiz = Quiz(settings: settings, gameCenter: gameCenter, shouldShuffle: false) 20 | let communGetter = StubCommunGetter() 21 | 22 | Current = World( 23 | analytics: analytics, 24 | reviewPrompter: TestReviewPrompter(), 25 | gameCenter: gameCenter, 26 | settings: settings, 27 | quiz: quiz, 28 | session: URLSession.stubSession(ratingsCount: 0), 29 | communGetter: communGetter, 30 | locale: StubLocale(languageCode: "en", regionCode: "US") 31 | ) 32 | 33 | let actionType = Commun.CommunType.website(actionTitle: ["en": "🐬"], cancelTitle: ["en": "🐉"], action: { didTapAction = true }) 34 | let commun = Commun(title: ["en": "🐋"], image: UIImage(), imageLabel: ["en": "🍕"], content: ["en": "🏴󠁧󠁢󠁷󠁬󠁳󠁿"], type: actionType, identifier: 0) 35 | let vm = CommunViewModel(commun: commun) 36 | 37 | XCTAssertFalse(didTapAction) 38 | vm.action() 39 | XCTAssert(didTapAction) 40 | XCTAssertEqual(vm.title, "🐋") 41 | XCTAssertEqual(vm.content, "🏴󠁧󠁢󠁷󠁬󠁳󠁿") 42 | XCTAssertEqual(vm.imageLabel, "🍕") 43 | XCTAssertEqual(vm.okayTitle, "") 44 | XCTAssertEqual(vm.cancelTitle, "🐉") 45 | XCTAssertEqual(vm.actionTitle, "🐬") 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /ConjugarTests/Controllers/ResultsVCTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ResultsVCTests.swift 3 | // ConjugarTests 4 | // 5 | // Created by Joshua Adams on 5/24/19. 6 | // Copyright © 2019 Josh Adams. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Conjugar 11 | 12 | class ResultsVCTests: XCTestCase { 13 | func testResultsVC() { 14 | var analytic = "" 15 | let settings = Settings(getterSetter: DictionaryGetterSetter()) 16 | settings.userRejectedGameCenter = true 17 | let gameCenter = TestGameCenter(isAuthenticated: true) 18 | let analytics = TestAnalyticsService(fire: { fired in analytic = fired }) 19 | let quiz = Quiz(settings: settings, gameCenter: gameCenter, shouldShuffle: false) 20 | let fakeRatingsCount = 42 21 | 22 | Current = World( 23 | analytics: analytics, 24 | reviewPrompter: TestReviewPrompter(), 25 | gameCenter: gameCenter, 26 | settings: settings, 27 | quiz: quiz, 28 | session: URLSession.stubSession(ratingsCount: fakeRatingsCount), 29 | communGetter: StubCommunGetter(), 30 | locale: StubLocale(languageCode: "en", regionCode: "US") 31 | ) 32 | 33 | let rvc = ResultsVC() 34 | 35 | XCTAssertNotNil(rvc) 36 | XCTAssertNotNil(rvc.resultsView) 37 | rvc.viewWillAppear(true) 38 | XCTAssertEqual(analytic, "visited viewController: \(ResultsVC.self) ") 39 | 40 | quiz.start() 41 | let expectedQuestionCount = 50 42 | (0 ..< expectedQuestionCount).forEach { _ in 43 | let wrongAnswer = "🥥" 44 | _ = quiz.process(proposedAnswer: wrongAnswer) 45 | } 46 | 47 | XCTAssertEqual(rvc.tableView(rvc.resultsView.table, numberOfRowsInSection: 0), expectedQuestionCount) 48 | 49 | let cell = rvc.tableView(rvc.resultsView.table, cellForRowAt: IndexPath(row: 0, section: 0)) 50 | XCTAssert(cell is ResultCell) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /ConjugarTests/Controllers/BrowseInfoVCTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BrowseInfoVCTests.swift 3 | // ConjugarTests 4 | // 5 | // Created by Joshua Adams on 9/2/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Conjugar 11 | 12 | class BrowseInfoVCTests: XCTestCase { 13 | func testBrowseInfoVC() { 14 | var analytic = "" 15 | let settings = Settings(getterSetter: DictionaryGetterSetter()) 16 | Current.analytics = TestAnalyticsService(fire: { fired in analytic = fired }) 17 | Current.settings = settings 18 | 19 | let bivc = BrowseInfoVC() 20 | let nc = MockNavigationC(rootViewController: bivc) 21 | 22 | XCTAssertNotNil(bivc) 23 | bivc.viewWillAppear(true) 24 | XCTAssertEqual(analytic, "visited viewController: \(BrowseInfoVC.self) ") 25 | XCTAssertEqual(bivc.tableView(UITableView(), numberOfRowsInSection: 0), 28) 26 | 27 | let biv = bivc.browseInfoView 28 | 29 | let easyCount = 9 30 | let easyModerateCount = 17 31 | let allCount = 28 32 | 33 | [(0, easyCount), (1, easyModerateCount), (2, allCount)].forEach { 34 | biv.difficultyControl.selectedSegmentIndex = $0.0 35 | XCTAssertEqual(bivc.tableView(UITableView(), numberOfRowsInSection: 0), $0.1) 36 | } 37 | 38 | bivc.tableView(UITableView(), didSelectRowAt: IndexPath(row: 0, section: 0)) 39 | XCTAssert(nc.pushedViewController is InfoVC) 40 | nc.popViewController(animated: false) 41 | 42 | [(0, Difficulty.easy), (1, .moderate), (2, .difficult)].forEach { 43 | biv.difficultyControl.selectedSegmentIndex = $0.0 44 | bivc.difficultyChanged(biv.difficultyControl) 45 | XCTAssertEqual(settings.infoDifficulty, $0.1) 46 | } 47 | 48 | bivc.infoSelectionDidChange(newHeading: "Terminology") 49 | XCTAssert(nc.pushedViewController is InfoVC) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Conjugar/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleSpokenName 6 | con hoo gar 7 | ITSAppUsesNonExemptEncryption 8 | 9 | CFBundleDevelopmentRegion 10 | en 11 | CFBundleExecutable 12 | $(EXECUTABLE_NAME) 13 | CFBundleIdentifier 14 | $(PRODUCT_BUNDLE_IDENTIFIER) 15 | CFBundleInfoDictionaryVersion 16 | 6.0 17 | CFBundleName 18 | $(PRODUCT_NAME) 19 | CFBundlePackageType 20 | APPL 21 | CFBundleShortVersionString 22 | $(MARKETING_VERSION) 23 | CFBundleVersion 24 | $(CURRENT_PROJECT_VERSION) 25 | LSRequiresIPhoneOS 26 | 27 | UILaunchStoryboardName 28 | LaunchScreen 29 | UIRequiredDeviceCapabilities 30 | 31 | armv7 32 | arm64 33 | 34 | UIRequiresFullScreen 35 | 36 | UIStatusBarStyle 37 | UIStatusBarStyleLightContent 38 | UISupportedInterfaceOrientations 39 | 40 | UIInterfaceOrientationPortrait 41 | 42 | UISupportedInterfaceOrientations~ipad 43 | 44 | UIInterfaceOrientationPortrait 45 | UIInterfaceOrientationPortraitUpsideDown 46 | UIInterfaceOrientationLandscapeLeft 47 | UIInterfaceOrientationLandscapeRight 48 | 49 | UIUserInterfaceStyle 50 | Dark 51 | UIViewControllerBasedStatusBarAppearance 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /Conjugar/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 3/31/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class AppDelegate: UIResponder, UIApplicationDelegate { 12 | var window: UIWindow? 13 | 14 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 15 | configureTabBar() 16 | configureNavBar() 17 | 18 | let uiTestingFlag = "enable-ui-testing" 19 | if CommandLine.arguments.contains(uiTestingFlag) { 20 | Current = World.uiTest(launchArguments: CommandLine.arguments) 21 | } 22 | 23 | Utterer.setup(settings: Current.settings) 24 | let mainTabBarVC = MainTabBarVC() 25 | 26 | window = UIWindow(frame: UIScreen.main.bounds) 27 | window?.rootViewController = mainTabBarVC 28 | window?.makeKeyAndVisible() 29 | 30 | return true 31 | } 32 | 33 | private func configureTabBar() { 34 | UITabBar.appearance().barTintColor = UIColor.black 35 | UITabBar.appearance().tintColor = Colors.yellow 36 | } 37 | 38 | private func configureNavBar() { 39 | UINavigationBar.appearance().barTintColor = UIColor.black 40 | UINavigationBar.appearance().tintColor = Colors.yellow 41 | UINavigationBar.appearance().titleTextAttributes = [NSAttributedString.Key(rawValue: NSAttributedString.Key.foregroundColor.rawValue): Colors.yellow] 42 | } 43 | 44 | func applicationWillResignActive(_ application: UIApplication) {} 45 | 46 | func applicationDidEnterBackground(_ application: UIApplication) {} 47 | 48 | func applicationWillEnterForeground(_ application: UIApplication) {} 49 | 50 | func applicationDidBecomeActive(_ application: UIApplication) { 51 | Current.analytics.recordBecameActive() 52 | } 53 | 54 | func applicationWillTerminate(_ application: UIApplication) {} 55 | } 56 | -------------------------------------------------------------------------------- /ConjugarTests/Controllers/MainTabBarVCTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainTabBarVCTests.swift 3 | // ConjugarTests 4 | // 5 | // Created by Joshua Adams on 3/31/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import SwiftUI 11 | @testable import Conjugar 12 | 13 | class MainTabBarVCTests: XCTestCase { 14 | func testMainTabBarVC() { 15 | let mtbvc = MainTabBarVC() 16 | XCTAssertNotNil(mtbvc) 17 | 18 | if let firstNavC = mtbvc.selectedViewController as? UINavigationController { 19 | if let browseVerbsVC = firstNavC.visibleViewController { 20 | if !(browseVerbsVC is BrowseVerbsVC) { 21 | XCTFail("First tab's UINavigationController's visibleViewController is not a BrowseVerbsVC.") 22 | } 23 | } 24 | } else { 25 | XCTFail("First tab is not a UINavigationController.") 26 | } 27 | 28 | mtbvc.selectedIndex = 1 29 | if let secondNavC = mtbvc.selectedViewController as? UINavigationController { 30 | if let quizVC = secondNavC.visibleViewController { 31 | if !(quizVC is QuizVC) { 32 | XCTFail("Second tab's UINavigationController's visibleViewController is not a QuizVC.") 33 | } 34 | } 35 | } else { 36 | XCTFail("Second tab is not a UINavigationController.") 37 | } 38 | 39 | mtbvc.selectedIndex = 2 40 | if let thirdNavC = mtbvc.selectedViewController as? UINavigationController { 41 | if let browseInfoVC = thirdNavC.visibleViewController { 42 | if !(browseInfoVC is BrowseInfoVC) { 43 | XCTFail("Third tab's UINavigationController's visibleViewController is not a BrowseInfoVC.") 44 | } 45 | } 46 | } else { 47 | XCTFail("Third tab is not a UINavigationController.") 48 | } 49 | 50 | mtbvc.selectedIndex = 3 51 | if mtbvc.selectedViewController == nil { 52 | XCTFail("Fourth tab's selectedViewController was nil.") 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /ConjugarTests/Models/PersonNumberTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PersonNumberTests.swift 3 | // ConjugarTests 4 | // 5 | // Created by Joshua Adams on 5/13/19. 6 | // Copyright © 2019 Josh Adams. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Conjugar 11 | 12 | class PersonNumberTests: XCTestCase { 13 | func testShortDisplayName() { 14 | var personNumber = PersonNumber.firstSingular 15 | XCTAssertEqual(personNumber.shortDisplayName, "1S") 16 | personNumber = .secondSingularTú 17 | XCTAssertEqual(personNumber.shortDisplayName, "2S") 18 | personNumber = .secondSingularVos 19 | XCTAssertEqual(personNumber.shortDisplayName, "2SV") 20 | personNumber = .thirdSingular 21 | XCTAssertEqual(personNumber.shortDisplayName, "3S") 22 | personNumber = .firstPlural 23 | XCTAssertEqual(personNumber.shortDisplayName, "1P") 24 | personNumber = .secondPlural 25 | XCTAssertEqual(personNumber.shortDisplayName, "2P") 26 | personNumber = .thirdPlural 27 | XCTAssertEqual(personNumber.shortDisplayName, "3P") 28 | personNumber = .none 29 | XCTAssertEqual(personNumber.shortDisplayName, "none") 30 | } 31 | 32 | func testPronoun() { 33 | var personNumber = PersonNumber.firstSingular 34 | XCTAssertEqual(personNumber.pronoun, "yo") 35 | personNumber = PersonNumber.secondSingularTú 36 | XCTAssertEqual(personNumber.pronoun, "tú") 37 | personNumber = PersonNumber.secondSingularVos 38 | XCTAssertEqual(personNumber.pronoun, "vos") 39 | personNumber = PersonNumber.thirdSingular 40 | XCTAssertEqual(personNumber.pronoun, "él") 41 | personNumber = PersonNumber.firstPlural 42 | XCTAssertEqual(personNumber.pronoun, "nosotros") 43 | personNumber = PersonNumber.secondPlural 44 | XCTAssertEqual(personNumber.pronoun, "vosotros") 45 | personNumber = PersonNumber.thirdPlural 46 | XCTAssertEqual(personNumber.pronoun, "ellas") 47 | personNumber = PersonNumber.none 48 | XCTAssertEqual(personNumber.pronoun, "none") 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Conjugar/ResultsVC.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ResultsVC.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 6/25/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class ResultsVC: UIViewController, UITableViewDelegate, UITableViewDataSource { 12 | var resultsView: ResultsUIV { 13 | if let castedView = view as? ResultsUIV { 14 | return castedView 15 | } else { 16 | fatalError(fatalCastMessage(view: ResultsUIV.self)) 17 | } 18 | } 19 | 20 | override func loadView() { 21 | let resultsView: ResultsUIV 22 | resultsView = ResultsUIV(frame: UIScreen.main.bounds) 23 | resultsView.setupTable(dataSource: self, delegate: self) 24 | navigationItem.titleView = UILabel.titleLabel(title: Localizations.Results.title) 25 | view = resultsView 26 | } 27 | 28 | override func viewWillAppear(_ animated: Bool) { 29 | super.viewWillAppear(animated) 30 | resultsView.difficulty.text = Current.quiz.lastDifficulty.localizedDifficulty 31 | resultsView.region.text = Current.quiz.lastRegion.localizedRegion 32 | resultsView.score.text = String(Current.quiz.score) 33 | resultsView.time.text = Current.quiz.elapsedTime.timeString 34 | Current.analytics.recordVisitation(viewController: "\(ResultsVC.self)") 35 | } 36 | 37 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 38 | return Current.quiz.questions.count 39 | } 40 | 41 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 42 | guard let cell = resultsView.table.dequeueReusableCell(withIdentifier: ResultCell.identifier) as? ResultCell else { 43 | fatalError("Could not dequeue \(ResultCell.self).") 44 | } 45 | let row = indexPath.row 46 | let question = Current.quiz.questions[row] 47 | cell.configure(verb: question.0, tense: question.1, personNumber: question.2, correctAnswer: Current.quiz.correctAnswers[row], proposedAnswer: Current.quiz.proposedAnswers[row]) 48 | return cell 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Conjugar/BrowseVerbsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BrowseVerbsUIV.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 7/16/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class BrowseVerbsUIV: UIView { 12 | @UsesAutoLayout 13 | var table: UITableView = { 14 | let tableView = UITableView() 15 | tableView.backgroundColor = Colors.black 16 | return tableView 17 | }() 18 | 19 | @UsesAutoLayout 20 | var filterControl: UISegmentedControl = { 21 | let control = UISegmentedControl(items: [ 22 | Localizations.Verb.irregular, 23 | Localizations.Verb.regular, 24 | Localizations.bothMasculine 25 | ]) 26 | control.selectedSegmentIndex = 0 27 | control.yellowfyText() 28 | return control 29 | }() 30 | 31 | required init(coder aDecoder: NSCoder) { 32 | NSCoder.fatalErrorNotImplemented() 33 | } 34 | 35 | override init(frame: CGRect) { 36 | super.init(frame: frame) 37 | [table, filterControl].forEach { 38 | addSubview($0) 39 | } 40 | 41 | NSLayoutConstraint.activate([ 42 | table.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), 43 | table.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), 44 | table.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), 45 | table.bottomAnchor.constraint(equalTo: filterControl.topAnchor, constant: -1.0 * Layout.defaultSpacing), 46 | filterControl.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), 47 | filterControl.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), 48 | filterControl.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -1.0 * Layout.defaultSpacing) 49 | ]) 50 | } 51 | 52 | func setupTable(dataSource: UITableViewDataSource, delegate: UITableViewDelegate) { 53 | table.dataSource = dataSource 54 | table.delegate = delegate 55 | table.register(VerbCell.self, forCellReuseIdentifier: VerbCell.identifier) 56 | } 57 | 58 | func reloadTableData() { 59 | table.reloadData() 60 | table.setContentOffset(CGPoint.zero, animated: false) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Conjugar/ConjugationCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConjugationCell.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 5/7/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class ConjugationCell: UITableViewCell { 12 | static let identifier = "ConjugationCell" 13 | 14 | @UsesAutoLayout 15 | var conjugation: UILabel = { 16 | let label = UILabel() 17 | label.textColor = Colors.yellow 18 | label.font = Fonts.smallCell 19 | label.textAlignment = .center 20 | label.adjustsFontSizeToFitWidth = true 21 | return label 22 | }() 23 | 24 | required init?(coder aDecoder: NSCoder) { 25 | NSCoder.fatalErrorNotImplemented() 26 | } 27 | 28 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 29 | super.init(style: style, reuseIdentifier: reuseIdentifier) 30 | addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(ConjugationCell.tap(_:)))) 31 | selectionStyle = .none 32 | backgroundColor = Colors.black 33 | addSubview(conjugation) 34 | 35 | NSLayoutConstraint.activate([ 36 | conjugation.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Layout.defaultSpacing), 37 | conjugation.trailingAnchor.constraint(equalTo: trailingAnchor, constant: Layout.defaultSpacing * -1.0), 38 | conjugation.centerYAnchor.constraint(equalTo: centerYAnchor) 39 | ]) 40 | } 41 | 42 | func configure(tense: Tense, personNumber: PersonNumber, conjugation: String) { 43 | var conjugation = conjugation 44 | if conjugation == Conjugator.defective { 45 | self.conjugation.text = "" 46 | } else { 47 | if tense == .imperativoPositivo || tense == .imperativoNegativo { 48 | conjugation = "¡" + conjugation + "!" 49 | } else { 50 | conjugation = personNumber.pronoun + " " + conjugation 51 | } 52 | self.conjugation.attributedText = conjugation.conjugatedString 53 | self.conjugation.setAccessibilityLabelInSpanish(conjugation) 54 | } 55 | } 56 | 57 | @objc func tap(_ sender: UITapGestureRecognizer) { 58 | Utterer.utter(conjugation.attributedText?.string ?? conjugation.text ?? "") 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Conjugar/MainTabBarVC.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainTabBarVC.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 7/15/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import SwiftUI 11 | 12 | class MainTabBarVC: UITabBarController { 13 | convenience init() { 14 | self.init(nibName: nil, bundle: nil) 15 | 16 | let browseVerbsNavC = UINavigationController(rootViewController: BrowseVerbsVC()) 17 | browseVerbsNavC.tabBarItem = UITabBarItem( 18 | title: Localizations.BrowseVerbs.localizedTitle, 19 | image: UIImage(named: BrowseVerbsVC.englishTitle), 20 | selectedImage: nil 21 | ) 22 | 23 | let quizNavC = UINavigationController(rootViewController: QuizVC()) 24 | quizNavC.tabBarItem = UITabBarItem( 25 | title: Localizations.Quiz.localizedTitle, 26 | image: UIImage(named: QuizVC.englishTitle), 27 | selectedImage: nil 28 | ) 29 | 30 | let settingsVC = UIHostingController(rootView: SettingsView()) 31 | Current.parentViewController = settingsVC 32 | settingsVC.tabBarItem = UITabBarItem( 33 | title: Localizations.Settings.localizedTitle, 34 | image: UIImage(named: SettingsView.englishTitle), 35 | selectedImage: nil 36 | ) 37 | 38 | let browseInfoNavC = UINavigationController(rootViewController: BrowseInfoVC()) 39 | browseInfoNavC.tabBarItem = UITabBarItem( 40 | title: Localizations.BrowseInfo.localizedTitle, 41 | image: UIImage(named: BrowseInfoVC.englishTitle), 42 | selectedImage: nil 43 | ) 44 | 45 | viewControllers = [browseVerbsNavC, quizNavC, browseInfoNavC, settingsVC] 46 | } 47 | 48 | override func viewDidLoad() { 49 | super.viewDidLoad() 50 | // Current.communGetter.getCommunication(completion: { [weak self] commun in 51 | // DispatchQueue.main.async { 52 | // let lastCommunIdentifierShown = Current.settings.lastCommunIdentifierShown 53 | // if Current.quiz.quizState != .inProgress && commun.identifier > lastCommunIdentifierShown { 54 | // let communVC = CommunVC(commun: commun) 55 | // communVC.modalPresentationStyle = .fullScreen 56 | // self?.present(communVC, animated: true) 57 | // Current.settings.lastCommunIdentifierShown = commun.identifier 58 | // } 59 | // } 60 | // }) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Conjugar/ResultCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ResultCell.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 6/25/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class ResultCell: UITableViewCell { 12 | static let identifier = "ResultCell" 13 | 14 | @UsesAutoLayout private(set) var verb = UILabel() 15 | @UsesAutoLayout private(set) var tensePersonNumber = UILabel() 16 | @UsesAutoLayout private(set) var correctAnswer = UILabel() 17 | @UsesAutoLayout private(set) var proposedAnswer = UILabel() 18 | 19 | required init?(coder aDecoder: NSCoder) { 20 | NSCoder.fatalErrorNotImplemented() 21 | } 22 | 23 | override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { 24 | super.init(style: style, reuseIdentifier: reuseIdentifier) 25 | [verb, tensePersonNumber, correctAnswer, proposedAnswer].forEach { 26 | $0.textColor = Colors.yellow 27 | $0.font = Fonts.smallCell 28 | addSubview($0) 29 | } 30 | verb.font = Fonts.regularCell 31 | backgroundColor = Colors.black 32 | selectionStyle = .none 33 | 34 | NSLayoutConstraint.activate([ 35 | verb.topAnchor.constraint(equalTo: topAnchor, constant: 4.0), 36 | verb.centerXAnchor.constraint(equalTo: centerXAnchor), 37 | tensePersonNumber.topAnchor.constraint(equalTo: verb.bottomAnchor, constant: 4.0), 38 | tensePersonNumber.centerXAnchor.constraint(equalTo: centerXAnchor), 39 | correctAnswer.topAnchor.constraint(equalTo: tensePersonNumber.bottomAnchor, constant: 4.0), 40 | correctAnswer.centerXAnchor.constraint(equalTo: centerXAnchor), 41 | proposedAnswer.topAnchor.constraint(equalTo: correctAnswer.bottomAnchor, constant: 4.0), 42 | proposedAnswer.centerXAnchor.constraint(equalTo: centerXAnchor) 43 | ]) 44 | } 45 | 46 | func configure(verb: String, tense: Tense, personNumber: PersonNumber, correctAnswer: String, proposedAnswer: String) { 47 | self.verb.text = verb.lowercased() 48 | tensePersonNumber.text = "\(tense.displayName), \(personNumber.shortDisplayName)" 49 | self.correctAnswer.attributedText = correctAnswer.conjugatedString 50 | self.proposedAnswer.text = proposedAnswer.lowercased() 51 | if correctAnswer.lowercased() != proposedAnswer.lowercased() { 52 | self.proposedAnswer.textColor = Colors.blue 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /ConjugarTests/Utils/ReviewPrompterTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReviewPrompterTests.swift 3 | // ConjugarTests 4 | // 5 | // Created by Joshua Adams on 11/21/18. 6 | // Copyright © 2018 Josh Adams. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Conjugar 11 | 12 | class ReviewPrompterTests: XCTestCase { 13 | func testPromptableActionHappened() { 14 | let now = Date() 15 | let smallAmountOfTime: TimeInterval = 5.0 16 | let recentPromptDate = now.addingTimeInterval(-1.0 * smallAmountOfTime) 17 | 18 | let formatter = DateFormatter() 19 | let format = "yyyy'-'MM'-'dd'T'HH':'mm':'ss'Z'" 20 | formatter.dateFormat = format 21 | 22 | var settingsDictionary1: [String: String] = [:] 23 | settingsDictionary1[Settings.lastReviewPromptDateKey] = formatter.string(from: recentPromptDate) 24 | let settings1 = Settings(getterSetter: DictionaryGetterSetter(dictionary: settingsDictionary1)) 25 | var didRequestReview = false 26 | let prompter1 = ReviewPrompter(settings: settings1, now: now, requestReview: { didRequestReview = true }) 27 | 28 | prompter1.promptableActionHappened() 29 | XCTAssertFalse(didRequestReview) 30 | 31 | settings1.promptActionCount = ReviewPrompter.promptModulo - 1 32 | XCTAssertFalse(didRequestReview) 33 | 34 | let longAgoDate = recentPromptDate.addingTimeInterval(-1.0 * ReviewPrompter.promptInterval) 35 | settings1.lastReviewPromptDate = longAgoDate 36 | settings1.promptActionCount = ReviewPrompter.promptModulo - 2 37 | prompter1.promptableActionHappened() 38 | XCTAssertFalse(didRequestReview) 39 | 40 | settings1.promptActionCount = ReviewPrompter.promptModulo - 1 41 | prompter1.promptableActionHappened() 42 | XCTAssert(didRequestReview) 43 | 44 | var settingsDictionary2: [String: String] = [:] 45 | settingsDictionary2[Settings.promptActionCountKey] = "\(ReviewPrompter.promptModulo - 1)" 46 | let settings2 = Settings(getterSetter: DictionaryGetterSetter(dictionary: settingsDictionary2)) 47 | let prompter2 = ReviewPrompter(settings: settings2, now: longAgoDate, requestReview: { didRequestReview = true }) 48 | 49 | didRequestReview = false 50 | prompter2.promptableActionHappened() 51 | XCTAssert(didRequestReview) 52 | 53 | didRequestReview = false 54 | prompter2.promptableActionHappened() 55 | XCTAssertFalse(didRequestReview) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /ConjugarTests/Analytics/AnalyticsServiceableTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnalyticsServiceableTests.swift 3 | // ConjugarTests 4 | // 5 | // Created by Joshua Adams on 4/25/19. 6 | // Copyright © 2019 Josh Adams. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Conjugar 11 | 12 | class AnalyticsServiceableTests: XCTestCase { 13 | private let nilServiceMessage = "TestAnalyticsService was nil." 14 | private let nilAnalyticsMessage = "analytics array was nil." 15 | 16 | func testRecordEvent() { 17 | var analytics: [String] = [] 18 | let service = TestAnalyticsService(fire: { event in 19 | analytics.append(event) 20 | }) 21 | 22 | let 🥥 = "🥥" 23 | XCTAssertFalse(analytics.contains(🥥)) 24 | service.recordEvent(🥥) 25 | XCTAssert(analytics.contains(🥥)) 26 | } 27 | 28 | func testRecordVisitation() { 29 | var analytics: [String] = [] 30 | let service = TestAnalyticsService(fire: { event in 31 | analytics.append(event) 32 | }) 33 | 34 | let pizzaViewController = "PizzaViewController" 35 | XCTAssertFalse(analytics.contains("\(service.visited) \(pizzaViewController)")) 36 | service.recordVisitation(viewController: pizzaViewController) 37 | XCTAssert(analytics.contains("\(service.visited) \(service.viewContröller): \(pizzaViewController) ")) 38 | } 39 | 40 | func testRecordQuizStart() { 41 | var analytics: [String] = [] 42 | let service = TestAnalyticsService(fire: { event in 43 | analytics.append(event) 44 | }) 45 | 46 | XCTAssertFalse(analytics.contains(service.quizStart)) 47 | service.recordQuizStart() 48 | XCTAssert(analytics.contains(service.quizStart)) 49 | } 50 | 51 | func testRecordQuizCompletion() { 52 | var analytics: [String] = [] 53 | let service = TestAnalyticsService(fire: { event in 54 | analytics.append(event) 55 | }) 56 | 57 | let score = 42 58 | XCTAssertFalse(analytics.contains("\(service.quizCompletion) \(service.scöre): \(score) ")) 59 | service.recordQuizCompletion(score: score) 60 | XCTAssert(analytics.contains("\(service.quizCompletion) \(service.scöre): \(score) ")) 61 | } 62 | 63 | func testRecordGameCenterAuth() { 64 | var analytics: [String] = [] 65 | let service = TestAnalyticsService(fire: { event in 66 | analytics.append(event) 67 | }) 68 | 69 | XCTAssertFalse(analytics.contains("\(service.gameCenterAuth)")) 70 | service.recordGameCenterAuth() 71 | XCTAssert(analytics.contains("\(service.gameCenterAuth)")) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Conjugar/RatingsFetcher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RatingsFetcher.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 3/1/19. 6 | // Copyright © 2019 Josh Adams. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct RatingsFetcher { 12 | static let iTunesID = "1236500467" 13 | static let errorMessage = "Fetching failed." 14 | 15 | private static let urlInitializationMessage = " URL could not be initializaed." 16 | 17 | static var iTunesURL: URL { 18 | guard let iTunesURL = URL(string: "https://itunes.apple.com/lookup?id=\(iTunesID)") else { 19 | fatalError("iTunes" + urlInitializationMessage) 20 | } 21 | return iTunesURL 22 | } 23 | 24 | static var reviewURL: URL { 25 | guard let reviewURL = URL(string: "https://itunes.apple.com/app/conjugar/id\(iTunesID)?action=write-review") else { 26 | fatalError("Rate/review" + urlInitializationMessage) 27 | } 28 | return reviewURL 29 | } 30 | 31 | static func fetchRatingsDescription(completion: @escaping (String) -> ()) { 32 | let request = URLRequest(url: RatingsFetcher.iTunesURL) 33 | 34 | let task = Current.session.dataTask(with: request) { (responseData, _, error) in 35 | if error != nil { 36 | completion(errorMessage) 37 | return 38 | } else if let responseData = responseData { 39 | guard 40 | let json = try? JSONSerialization.jsonObject(with: responseData, options: []) as? [String: Any], 41 | let results = json["results"] as? [[String: Any]], 42 | results.count == 1 43 | else { 44 | completion(errorMessage) 45 | return 46 | } 47 | 48 | let ratingsCount = (results[0])["userRatingCountForCurrentVersion"] as? Int ?? 0 49 | 50 | let description: String 51 | let exhortation = " ¡Sé la primera o el primero!" 52 | 53 | switch ratingsCount { 54 | case 0: 55 | description = Localizations.Settings.noRating + exhortation 56 | case 1: 57 | description = Localizations.Settings.oneRating + " " + Localizations.Settings.addYours 58 | default: 59 | description = String(format: Localizations.Settings.multipleRatings, ratingsCount) + " " + Localizations.Settings.addYours 60 | } 61 | completion(description) 62 | } 63 | } 64 | 65 | task.resume() 66 | } 67 | 68 | static func stubData(ratingsCount: Int) -> Data { 69 | return Data("{ \"resultCount\":1, \"results\": [ { \"userRatingCountForCurrentVersion\": \(ratingsCount) } ] }".utf8) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Conjugar/CommunViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommunViewModel.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 12/15/20. 6 | // Copyright © 2020 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | struct CommunViewModel { 12 | private(set) var title = "" 13 | private(set) var content = "" 14 | private(set) var image = UIImage() 15 | private(set) var imageLabel = "" 16 | private(set) var okayTitle = "" 17 | private(set) var shouldShowOkay = false 18 | private(set) var cancelTitle = "" 19 | private(set) var shouldShowCancel = false 20 | private(set) var actionTitle = "" 21 | private(set) var shouldShowAction = false 22 | private(set) var action: () -> () = {} 23 | let identifier: Int 24 | 25 | init(commun: Commun) { 26 | identifier = commun.identifier 27 | configure(commun: commun) 28 | } 29 | 30 | private mutating func configure(commun: Commun) { 31 | title = stringFromDict(commun.title) 32 | content = stringFromDict(commun.content) 33 | image = commun.image 34 | imageLabel = stringFromDict(commun.imageLabel) 35 | 36 | switch commun.type { 37 | case .information(okayTitle: let okayTitle): 38 | self.okayTitle = stringFromDict(okayTitle) 39 | shouldShowOkay = true 40 | case .newVersion(okayTitle: let okayTitle, actionTitle: let actionTitle, cancelTitle: let cancelTitle, action: let action, alreadyUpdated: let alreadyUpdated): 41 | self.actionTitle = stringFromDict(actionTitle) 42 | self.cancelTitle = stringFromDict(cancelTitle) 43 | self.okayTitle = stringFromDict(okayTitle) 44 | shouldShowCancel = !alreadyUpdated 45 | shouldShowAction = !alreadyUpdated 46 | shouldShowOkay = alreadyUpdated 47 | self.action = action 48 | case .email(actionTitle: let actionTitle, cancelTitle: let cancelTitle, let action): 49 | self.actionTitle = stringFromDict(actionTitle) 50 | self.cancelTitle = stringFromDict(cancelTitle) 51 | shouldShowCancel = true 52 | shouldShowAction = true 53 | self.action = action 54 | case .website(actionTitle: let actionTitle, cancelTitle: let cancelTitle, action: let action): 55 | self.actionTitle = stringFromDict(actionTitle) 56 | self.cancelTitle = stringFromDict(cancelTitle) 57 | shouldShowCancel = true 58 | shouldShowAction = true 59 | self.action = action 60 | } 61 | } 62 | 63 | private func stringFromDict(_ dict: [String: String]) -> String { 64 | dict[Current.locale.languageCode] ?? dict[Current.locale.defaultLanguageCode] ?? "" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Conjugar/BrowseInfoUIV.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BrowseInfoUIV.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 7/30/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class BrowseInfoUIV: UIView { 12 | @UsesAutoLayout 13 | var table: UITableView = { 14 | let tableView = UITableView() 15 | tableView.backgroundColor = Colors.black 16 | return tableView 17 | }() 18 | 19 | @UsesAutoLayout 20 | var difficultyControl: UISegmentedControl = { 21 | let control = UISegmentedControl(items: [ 22 | Localizations.BrowseInfo.easy, 23 | Localizations.BrowseInfo.easyAndModerate, 24 | Localizations.BrowseInfo.easyModerateAndDifficult 25 | ]) 26 | control.selectedSegmentIndex = 0 27 | control.yellowfyText() 28 | return control 29 | }() 30 | 31 | @UsesAutoLayout 32 | private var difficultyLabel: UILabel = { 33 | let label = UILabel() 34 | label.text = Localizations.BrowseInfo.filter 35 | label.textAlignment = .center 36 | label.font = Fonts.smallBody 37 | label.textColor = Colors.yellow 38 | return label 39 | }() 40 | 41 | required init(coder aDecoder: NSCoder) { 42 | NSCoder.fatalErrorNotImplemented() 43 | } 44 | 45 | override init(frame: CGRect) { 46 | super.init(frame: frame) 47 | [table, difficultyLabel, difficultyControl].forEach { 48 | addSubview($0) 49 | } 50 | 51 | NSLayoutConstraint.activate([ 52 | table.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), 53 | table.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), 54 | table.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), 55 | table.bottomAnchor.constraint(equalTo: difficultyControl.topAnchor, constant: Layout.defaultSpacing * -1.0), 56 | 57 | difficultyControl.centerXAnchor.constraint(equalTo: centerXAnchor), 58 | difficultyControl.bottomAnchor.constraint(equalTo: difficultyLabel.topAnchor, constant: Layout.defaultSpacing * -1.0), 59 | 60 | difficultyLabel.centerXAnchor.constraint(equalTo: centerXAnchor), 61 | difficultyLabel.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -1.0 * Layout.defaultSpacing) 62 | ]) 63 | } 64 | 65 | func setupTable(dataSource: UITableViewDataSource, delegate: UITableViewDelegate) { 66 | table.dataSource = dataSource 67 | table.delegate = delegate 68 | table.register(InfoCell.self, forCellReuseIdentifier: InfoCell.identifier) 69 | } 70 | 71 | func reloadTableData() { 72 | table.reloadData() 73 | table.setContentOffset(CGPoint.zero, animated: false) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Conjugar/GameCenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GameCenter.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 6/26/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | import GameKit 10 | 11 | class GameCenter: NSObject, GameCenterable, GKGameCenterControllerDelegate { 12 | static let shared = GameCenter() 13 | var isAuthenticated = false 14 | private let localPlayer = GKLocalPlayer.local 15 | private var leaderboardIdentifier = "" 16 | private var onViewController: UIViewController? 17 | 18 | private override init() {} 19 | 20 | func authenticate(onViewController: UIViewController, completion: ((Bool) -> Void)? = nil) { 21 | self.onViewController = onViewController 22 | 23 | localPlayer.authenticateHandler = { viewController, _ in 24 | if let viewController = viewController { 25 | onViewController.present(viewController, animated: true, completion: nil) 26 | } else if self.localPlayer.isAuthenticated { 27 | // print("AUTHENTICATED displayName: \(self.localPlayer.displayName) alias: \(self.localPlayer.alias) playerID: \(self.localPlayer.playerID)") 28 | Current.analytics.recordGameCenterAuth() 29 | self.isAuthenticated = true 30 | SoundPlayer.playRandomApplause() 31 | self.localPlayer.loadDefaultLeaderboardIdentifier { identifier, _ in 32 | self.leaderboardIdentifier = identifier ?? "ERROR" 33 | // print("identifier: \(self.leaderboardIdentifier)") 34 | } 35 | completion?(true) 36 | } else { 37 | SoundPlayer.play(.sadTrombone) 38 | UIAlertController.showMessage(Localizations.gameCenterFailure, title: "😰", okTitle: Localizations.gotIt, onViewController: onViewController) 39 | self.isAuthenticated = false 40 | completion?(false) 41 | } 42 | } 43 | } 44 | 45 | func reportScore(_ score: Int) { 46 | guard isAuthenticated else { 47 | return 48 | } 49 | 50 | GKLeaderboard.submitScore(score, context: 0, player: localPlayer, leaderboardIDs: [leaderboardIdentifier], completionHandler: { _ in }) 51 | } 52 | 53 | func showLeaderboard() { 54 | guard isAuthenticated else { 55 | return 56 | } 57 | let gcViewController = GKGameCenterViewController(leaderboardID: leaderboardIdentifier, playerScope: .global, timeScope: .allTime) 58 | gcViewController.gameCenterDelegate = self 59 | onViewController?.present(gcViewController, animated: true, completion: nil) 60 | } 61 | 62 | func gameCenterViewControllerDidFinish(_ gameCenterViewController: GKGameCenterViewController) { 63 | gameCenterViewController.dismiss(animated: true, completion: nil) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Conjugar/BrowseVerbsVC.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BrowseVerbsVC.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 3/31/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class BrowseVerbsVC: UIViewController, UITableViewDelegate, UITableViewDataSource { 12 | static let englishTitle = "Browse" 13 | 14 | private var allVerbs: [String] = [] 15 | private var regularVerbs: [String] = [] 16 | private var irregularVerbs: [String] = [] 17 | 18 | private var currentVerbs: [String] { 19 | switch browseVerbsView.filterControl.selectedSegmentIndex { 20 | case 0: 21 | return irregularVerbs 22 | case 1: 23 | return regularVerbs 24 | case 2: 25 | return allVerbs 26 | default: 27 | fatalError("Invalid verb-filter index.") 28 | } 29 | } 30 | 31 | var browseVerbsView: BrowseVerbsUIV { 32 | if let castedView = view as? BrowseVerbsUIV { 33 | return castedView 34 | } else { 35 | fatalError(fatalCastMessage(view: BrowseVerbsUIV.self)) 36 | } 37 | } 38 | 39 | override func loadView() { 40 | let browseVerbsView = BrowseVerbsUIV(frame: UIScreen.main.bounds) 41 | browseVerbsView.setupTable(dataSource: self, delegate: self) 42 | browseVerbsView.filterControl.addTarget(self, action: #selector(BrowseVerbsVC.valueChanged(_:)), for: .valueChanged) 43 | allVerbs = Conjugator.shared.allVerbs 44 | regularVerbs = Conjugator.shared.regularVerbs 45 | irregularVerbs = Conjugator.shared.irregularVerbs 46 | navigationItem.titleView = UILabel.titleLabel(title: Localizations.BrowseVerbs.localizedTitle) 47 | view = browseVerbsView 48 | Current.reviewPrompter.promptableActionHappened() 49 | } 50 | 51 | override func viewWillAppear(_ animated: Bool) { 52 | super.viewWillAppear(animated) 53 | browseVerbsView.isHidden = false 54 | Current.analytics.recordVisitation(viewController: "\(BrowseVerbsVC.self)") 55 | } 56 | 57 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 58 | return currentVerbs.count 59 | } 60 | 61 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 62 | guard let cell = tableView.dequeueReusableCell(withIdentifier: VerbCell.identifier) as? VerbCell else { 63 | fatalError("Could not dequeue \(VerbCell.self).") 64 | } 65 | cell.configure(verb: currentVerbs[indexPath.row]) 66 | return cell 67 | } 68 | 69 | func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 70 | tableView.deselectRow(at: indexPath, animated: false) 71 | let verbVC = VerbVC(verb: currentVerbs[indexPath.row]) 72 | browseVerbsView.isHidden = true 73 | navigationController?.pushViewController(verbVC, animated: true) 74 | } 75 | 76 | @objc func valueChanged(_ sender: UISegmentedControl) { 77 | browseVerbsView.reloadTableData() 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /ConjugarTests/Models/QuizTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QuizTests.swift 3 | // ConjugarTests 4 | // 5 | // Created by Joshua Adams on 12/3/18. 6 | // Copyright © 2018 Josh Adams. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Conjugar 11 | 12 | // swiftlint:disable private_over_fileprivate 13 | fileprivate let difficultSpain = 750 14 | // swiftlint:enable private_over_fileprivate 15 | 16 | class QuizTests: XCTestCase { 17 | private let testGameCenter = TestGameCenter() 18 | 19 | func testQuiz() { 20 | let spain = Region.spain.rawValue 21 | let latinAmerica = Region.latinAmerica.rawValue 22 | let difficult = Difficulty.difficult.rawValue 23 | let moderate = Difficulty.moderate.rawValue 24 | let easy = Difficulty.easy.rawValue 25 | 26 | let difficultLatinAmerica = 624 27 | let moderateSpain = 500 28 | let moderateLatinAmerica = 416 29 | let easySpain = 250 30 | let easyLatinAmerica = 208 31 | 32 | [(spain, difficult, difficultSpain), 33 | (latinAmerica, difficult, difficultLatinAmerica), 34 | (spain, moderate, moderateSpain), 35 | (latinAmerica, moderate, moderateLatinAmerica), 36 | (spain, easy, easySpain), 37 | (latinAmerica, easy, easyLatinAmerica) 38 | ].forEach { region, difficulty, maxScore in 39 | let settings = Settings(getterSetter: DictionaryGetterSetter(dictionary: [Settings.difficultyKey: difficulty, Settings.regionKey: region])) 40 | let quiz = Quiz(settings: settings, gameCenter: testGameCenter, shouldShuffle: true) 41 | _ = TestQuizDelegate(quiz: quiz, onFinish: { score in 42 | XCTAssertEqual(score, maxScore) 43 | }) 44 | } 45 | } 46 | } 47 | 48 | class TestQuizDelegate: QuizDelegate { 49 | let quiz: Quiz 50 | let onFinish: (Int) -> () 51 | private var score = 0 52 | 53 | init(quiz: Quiz, onFinish: @escaping (Int) -> ()) { 54 | self.quiz = quiz 55 | self.onFinish = onFinish 56 | quiz.delegate = self 57 | quiz.start() 58 | } 59 | 60 | func questionDidChange(verb: String, tense: Tense, personNumber: PersonNumber) { 61 | let conjugationResult = Conjugator.shared.conjugate(infinitive: verb, tense: tense, personNumber: personNumber) 62 | switch conjugationResult { 63 | case let .success(value): 64 | _ = quiz.process(proposedAnswer: value) 65 | default: 66 | fatalError("Conjugation failed during unit test.") 67 | } 68 | } 69 | 70 | func quizDidFinish() { 71 | onFinish(quiz.score) 72 | } 73 | 74 | func scoreDidChange(newScore: Int) { 75 | score = newScore 76 | XCTAssert(score >= 0 && score <= difficultSpain) 77 | } 78 | 79 | func timeDidChange(newTime: Int) { 80 | // Note: The time is always 0, so I'm not going to bother testing it. 81 | // I could slow down the test so it takes longer than 0 second, but 82 | // that would be contrary to the quickness goal of unit tests. 83 | } 84 | 85 | func progressDidChange(current: Int, total: Int) { 86 | let questionCount = 50 87 | XCTAssert(current >= 0 && current < questionCount) 88 | XCTAssertEqual(total, questionCount) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /ConjugarTests/Controllers/CommunVCTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommunVCTests.swift 3 | // ConjugarTests 4 | // 5 | // Created by Joshua Adams on 12/21/20. 6 | // Copyright © 2020 Josh Adams. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Conjugar 11 | 12 | class CommunVCTests: XCTestCase { 13 | func testTaps() { 14 | let settings = Settings(getterSetter: DictionaryGetterSetter()) 15 | let gameCenter = TestGameCenter(isAuthenticated: false) 16 | let analytics = TestAnalyticsService() 17 | let quiz = Quiz(settings: settings, gameCenter: gameCenter, shouldShuffle: false) 18 | let communGetter = StubCommunGetter() 19 | 20 | Current = World( 21 | analytics: analytics, 22 | reviewPrompter: TestReviewPrompter(), 23 | gameCenter: gameCenter, 24 | settings: settings, 25 | quiz: quiz, 26 | session: URLSession.stubSession(ratingsCount: 0), 27 | communGetter: communGetter, 28 | locale: StubLocale(languageCode: "en", regionCode: "US") 29 | ) 30 | 31 | let window = UIWindow(frame: UIScreen.main.bounds) 32 | let rootVC = UIViewController() 33 | window.rootViewController = rootVC 34 | window.makeKeyAndVisible() 35 | var cvc = CommunVC(commun: communGetter.information) 36 | 37 | rootVC.present(cvc, animated: false) 38 | XCTAssert(rootVC.presentedViewController is CommunVC) 39 | var exp = expectation(description: "tap happened") 40 | cvc.unitTestCompletion = { 41 | XCTAssertNil(rootVC.presentedViewController) 42 | exp.fulfill() 43 | } 44 | cvc.tapClose() 45 | let timeout: TimeInterval = 1.0 46 | waitForExpectations(timeout: timeout) 47 | 48 | rootVC.present(cvc, animated: false) 49 | XCTAssert(rootVC.presentedViewController is CommunVC) 50 | exp = expectation(description: "tap happened") 51 | cvc.unitTestCompletion = { 52 | XCTAssertNil(rootVC.presentedViewController) 53 | exp.fulfill() 54 | } 55 | cvc.tapOkay() 56 | waitForExpectations(timeout: timeout) 57 | 58 | var didTapAction = false 59 | let ctaType = Commun.CommunType.website(actionTitle: ["en": "🐬"], cancelTitle: ["en": "🐉"], action: { didTapAction = true }) 60 | let ctaCommun = Commun(title: ["en": "🥥"], image: UIImage(), imageLabel: ["en": "🍕"], content: ["en": "🐋"], type: ctaType, identifier: 0) 61 | cvc = CommunVC(commun: ctaCommun) 62 | 63 | rootVC.present(cvc, animated: false) 64 | XCTAssert(rootVC.presentedViewController is CommunVC) 65 | exp = expectation(description: "tap happened") 66 | cvc.unitTestCompletion = { 67 | XCTAssertNil(rootVC.presentedViewController) 68 | exp.fulfill() 69 | } 70 | cvc.tapCancel() 71 | waitForExpectations(timeout: timeout) 72 | 73 | rootVC.present(cvc, animated: false) 74 | XCTAssert(rootVC.presentedViewController is CommunVC) 75 | exp = expectation(description: "tap happened") 76 | cvc.unitTestCompletion = { 77 | XCTAssertNil(rootVC.presentedViewController) 78 | XCTAssert(didTapAction) 79 | exp.fulfill() 80 | } 81 | cvc.tapAction() 82 | waitForExpectations(timeout: timeout) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /ConjugarTests/Controllers/QuizVCTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QuizVCTests.swift 3 | // ConjugarTests 4 | // 5 | // Created by Joshua Adams on 5/24/19. 6 | // Copyright © 2019 Josh Adams. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Conjugar 11 | 12 | class QuizVCTests: XCTestCase { 13 | func testQuizVC() { 14 | var analytic = "" 15 | let settings = Settings(getterSetter: DictionaryGetterSetter()) 16 | settings.userRejectedGameCenter = true 17 | let gameCenter = TestGameCenter(isAuthenticated: false) 18 | let analytics = TestAnalyticsService(fire: { fired in analytic = fired }) 19 | let quiz = Quiz(settings: settings, gameCenter: gameCenter, shouldShuffle: false) 20 | let fakeRatingsCount = 42 21 | 22 | Current = World( 23 | analytics: analytics, 24 | reviewPrompter: TestReviewPrompter(), 25 | gameCenter: gameCenter, 26 | settings: settings, 27 | quiz: quiz, 28 | session: URLSession.stubSession(ratingsCount: fakeRatingsCount), 29 | communGetter: StubCommunGetter(), 30 | locale: StubLocale(languageCode: "en", regionCode: "US") 31 | ) 32 | 33 | let qvc = QuizVC() 34 | 35 | XCTAssertNotNil(qvc) 36 | XCTAssertNotNil(qvc.quizView) 37 | qvc.viewWillAppear(true) 38 | XCTAssertEqual(analytic, "visited viewController: \(QuizVC.self) ") 39 | let quizView = qvc.quizView 40 | XCTAssertEqual(quizView.startRestartButton.titleLabel?.text, "Start") 41 | qvc.startRestart() 42 | XCTAssertEqual(quizView.startRestartButton.titleLabel?.text, "Restart") 43 | qvc.viewWillAppear(true) 44 | XCTAssertEqual(quizView.startRestartButton.titleLabel?.text, "Restart") 45 | 46 | XCTAssertEqual(quizView.score.text, "0") 47 | XCTAssertEqual(quizView.progress.text, "1 / 50") 48 | quizView.conjugationField.text = "caminas" 49 | XCTAssert(qvc.textFieldShouldReturn(quizView.conjugationField)) 50 | [quizView.lastLabel, quizView.last, quizView.correctLabel, quizView.correct].forEach { 51 | XCTAssert($0.isHidden) 52 | } 53 | 54 | let wrongAnswer = "🥥" 55 | let correctAnswer = "anda" 56 | XCTAssertEqual(quizView.score.text, "10") 57 | XCTAssertEqual(quizView.progress.text, "2 / 50") 58 | quizView.conjugationField.text = wrongAnswer 59 | XCTAssert(qvc.textFieldShouldReturn(quizView.conjugationField)) 60 | [quizView.lastLabel, quizView.last, quizView.correctLabel, quizView.correct].forEach { 61 | XCTAssertFalse($0.isHidden) 62 | } 63 | XCTAssertEqual(quizView.last.text, wrongAnswer) 64 | XCTAssertEqual(quizView.correct.text, correctAnswer) 65 | 66 | let remainingQuestionCount = 48 67 | for _ in 0 ..< remainingQuestionCount { 68 | quizView.conjugationField.text = wrongAnswer 69 | XCTAssert(qvc.textFieldShouldReturn(quizView.conjugationField)) 70 | } 71 | let expectedCompletionAnalytic = "quizCompletion score: 4 " 72 | XCTAssertEqual(analytic, expectedCompletionAnalytic) 73 | 74 | XCTAssert(quizView.score.isHidden) 75 | qvc.startRestart() 76 | XCTAssertFalse(quizView.score.isHidden) 77 | qvc.quit() 78 | XCTAssert(quizView.score.isHidden) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Conjugar](Conjugar/launch.png "Conjugar's Launch Screen") 2 | 3 | ### Introduction 4 | 5 | **Conjugar** is an iPhone™ app for learning Spanish verb conjugations. **Conjugar** conjugates most Spanish verbs, regular and irregular, in **all** Spanish verb tenses. There is a quiz mode with three difficulty levels. Results from quizzes are available in Game Center™. On a pedagogical note, **Conjugar** contains descriptions of the tenses. 6 | 7 | **Conjugar** uses dependency injection (DI) and programmatic layout (PL). Thus, if you are curious about how to implement DI or PL, **Conjugar** may be instructive. I have written tutorials on [DI](https://racecondition.software/blog/dependency-injection/) and [PL](https://racecondition.software/blog/programmatic-layout/). 8 | 9 | ### Installation 10 | 11 | **Conjugar** is available for free download in the iOS App Store™. Tap the logo below to install. 12 | 13 | [![Install](apple.png)](https://itunes.apple.com/us/app/conjugar/id1236500467?mt=8) 14 | 15 | Alternatively, you can clone this repo and build, using Xcode™, **Conjugar** yourself. 16 | 17 | **Conjugar** is currently using AWS Pinpoint analytics. The two relevant frameworks are in source control, but the configuration files and folder, in particular `awsconfiguration.json`, `.amplifyrc`, and `amplify`, respectively, are excluded from source control by the `.gitignore` file. For instructions on Pinpoint configuration, see this excellent [tutorial](https://itnext.io/integrate-analytics-into-your-ios-swift-applications-with-aws-amplify-20d31fe0a20e). 18 | 19 | If you want to build **Conjugar** without using AWS Pinpoint analytics, you can use the following workaround: 20 | 21 | * Remove AWSFrameworks from the 'Embed Frameworks' build phase. 22 | * Comment out script in the the Pinpoint Hocus Pocus build phase. 23 | * Remove `awsconfiguration.json` from being copied in the Copy Resources build phase. 24 | * Comment out `import AWSPinpoint` and all the contents of the methods in AWSAnalyticsService.swift. 25 | 26 | Please make sure to avoid committing these changes! 27 | 28 | ### License 29 | 30 | If Conjugar is in the App Store, why is the code on GitHub? I created this app to demonstrate programmatic layout for a conference talk, and I wish to provide helpful example code for folks who are curious about programmatic layout. I originally released Conjugar's source code under the MIT License because that license is maximally convenient for would-be users of the programmatic-layout code. This was a mistake. Some dirtbag released a _clone_ of Conjugar on the App Store that differs only in that it has a hideous app icon, that it requests push-notification permission, and that it crashes on launch. I have changed the MIT License to the GNU Affero General Public License in order to impose onerous requirements on would-be cloners of Conjugar. 31 | 32 | ### Screenshots 33 | 34 | ![Conjugar](Conjugar/browse.png "Browse View of Verbs") 35 | 36 | ![Conjugar](Conjugar/verb.png "One Verb's Conjugations") 37 | 38 | ![Conjugar](Conjugar/quiz.png "Quiz in Progress") 39 | 40 | ![Conjugar](Conjugar/browseInfo.png "Info Available") 41 | 42 | ![Conjugar](Conjugar/info.png "Info on One Tense") 43 | 44 | ![Conjugar](Conjugar/GameCenter.png "Conjugar in Game Center") 45 | 46 | ![Conjugar](Conjugar/leaderboard.png "Conjugar's Game Center Leaderboard") 47 | -------------------------------------------------------------------------------- /Conjugar/VerbFamilies.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VerbFamilies.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 6/10/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | struct VerbFamilies { 10 | static let regularArVerbs = ["hablar", "caminar", "andar", "trabajar", "estudiar", "escuchar", "visitar", "viajar", "enseñar", "llevar", "bailar", "nadar", "cocinar", "charlar", "platicar", "llorar", "esperar", "buscar", "mirar", "pintar", "gastar", "ganar", "comprar", "tocar", "tomar", "sacar", "ayudar", "cantar", "desear", "necesitar", "cortar", "contestar", "dibujar", "clonar", "datar", "distar", "empinar", "encalar", "esposar", "formar", "glosar", "golpear", "grapar", "hibernar", "maltear", "manar", "nublar", "penar", "ponderar", "rasar", "seriar", "trinchar", "procesar", "declarar", "helar", "usar", "regresar", "quedar", "lavar", "limpiar", "amar"] 11 | 12 | static let regularIrVerbs = ["vivir", "existir", "ocurrir", "recibir", "permitir", "partir", "cumplir", "decidir", "subir", "sufrir", "compartir", "consistir", "insistir", "asistir", "discutir", "unir", "coincidir", "distinguir", "definir", "admitir", "acudir", "nutrir", "evadir"] 13 | 14 | static let regularErVerbs = ["comer", "beber", "leer", "aprender", "comprender", "correr", "deber", "vender", "romper", "temer", "reprender", "barrer", "cometer", "poseer", "responder", "prometer", "meter", "someter", "absorber", "emprender", "coser", "ceder", "exceder", "ofender", "esconder", "lamer", "tejer", "esconder"] 15 | 16 | static let allRegularVerbs = VerbFamilies.regularArVerbs + VerbFamilies.regularErVerbs + VerbFamilies.regularIrVerbs 17 | 18 | static let irregularPresenteDeIndicativoVerbs = ["ser", "ir", "dormir", "hacer", "morir", "morder", "oír", "poder", "haber", "sentir", "sentar"] 19 | 20 | static let irregularImperfectivoVerbs = ["ser", "ir", "ver"] 21 | 22 | static let irregularPreteritoVerbs = ["ser", "ir", "dar", "poner", "poder", "estar", "tener", "andar", "saber", "haber", "caber", "hacer", "venir", "querer", "decir", "traer", "conducir"] 23 | 24 | static let irregularRaizFuturaVerbs = ["haber", "saber", "caber", "poder", "querer", "poner", "tener", "venir", "salir", "valer", "decir", "hacer"] 25 | 26 | static let irregularPresenteDeSubjuntivoVerbs = ["ser", "ir", "haber", "saber", "pensar", "perder", "sentir", "dormir", "pedir", "crecer", "conocer", "lucir", "conducir", "huir", "construir", "estar", "dar", "caber", "decir", "hacer", "caer", "oír", "traer", "poner", "salir", "tener", "valer", "venir", "ver", "jugar", "argüir", "elegir", "colegir", "manecer", "anochecer", "cazar", "granizar"] 27 | 28 | static let irregularTuImperativoVerbs = ["ser", "ir", "decir", "hacer", "poner", "salir", "tener", "venir", "componer", "obtener", "medir", "pedir", "oír", "elegir", "colegir"] 29 | 30 | static let irregularVosImperativoVerbs = ["ser", "ir"] 31 | 32 | static let irregularParticipioVerbs = ["abrir", "cubrir", "decir", "escribir", "hacer", "morir", "poner", "resolver", "romper", "ver", "volver", "pudrir"] 33 | 34 | static let irregularGerundioVerbs = ["poder", "sentir", "medir", "dormir", "caer", "leer", "traer", "construir", "huir", "oír", "ir", "tañer", "bullir", "argüir", "elegir", "colegir", "hervir"] 35 | 36 | static let thirdPersonSingularOnlyVerbs = ["amanecer", "anochecer", "llover", "granizar", "nevar", "relampaguear", "tronar"] 37 | } 38 | -------------------------------------------------------------------------------- /Conjugar/CommunVC.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CommunVC.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 12/13/20. 6 | // Copyright © 2020 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class CommunVC: UIViewController { 12 | var communView: CommunUIV { 13 | if let castedView = view as? CommunUIV { 14 | return castedView 15 | } else { 16 | fatalError(fatalCastMessage(view: CommunUIV.self)) 17 | } 18 | } 19 | 20 | var viewModel: CommunViewModel? 21 | var unitTestCompletion: (() -> ())? 22 | private let unknownIdentifier = -42 23 | 24 | init(commun: Commun) { 25 | super.init(nibName: nil, bundle: nil) 26 | viewModel = CommunViewModel(commun: commun) 27 | } 28 | 29 | required init?(coder aDecoder: NSCoder) { 30 | NSCoder.fatalErrorNotImplemented() 31 | } 32 | 33 | override func loadView() { 34 | let communView: CommunUIV 35 | communView = CommunUIV(frame: UIScreen.main.bounds) 36 | communView.closeButton.addTarget(self, action: #selector(tapClose), for: .touchUpInside) 37 | communView.okayButton.addTarget(self, action: #selector(tapOkay), for: .touchUpInside) 38 | communView.actionButton.addTarget(self, action: #selector(tapAction), for: .touchUpInside) 39 | communView.cancelButton.addTarget(self, action: #selector(tapCancel), for: .touchUpInside) 40 | view = communView 41 | configureUI() 42 | } 43 | 44 | override func viewWillAppear(_ animated: Bool) { 45 | super.viewWillAppear(animated) 46 | if let identifier = viewModel?.identifier { 47 | Current.analytics.recordCommunVisitation(identifier: identifier) 48 | } 49 | } 50 | 51 | private func configureUI() { 52 | guard let viewModel = viewModel else { 53 | fatalError("\(CommunViewModel.self) not initialized.") 54 | } 55 | 56 | communView.title.text = viewModel.title 57 | communView.content.text = viewModel.content 58 | communView.imageView.image = viewModel.image 59 | communView.imageView.accessibilityLabel = viewModel.imageLabel 60 | communView.okayButton.isHidden = !viewModel.shouldShowOkay 61 | communView.okayButton.setTitle(viewModel.okayTitle, for: .normal) 62 | communView.cancelButton.isHidden = !viewModel.shouldShowCancel 63 | communView.cancelButton.setTitle(viewModel.cancelTitle, for: .normal) 64 | communView.actionButton.isHidden = !viewModel.shouldShowAction 65 | communView.actionButton.setTitle(viewModel.actionTitle, for: .normal) 66 | } 67 | 68 | @objc func tapClose() { 69 | Current.analytics.recordCloseTap(identifier: viewModel?.identifier ?? unknownIdentifier) 70 | dismiss(animated: true, completion: unitTestCompletion) 71 | } 72 | 73 | @objc func tapOkay() { 74 | Current.analytics.recordOkayTap(identifier: viewModel?.identifier ?? unknownIdentifier) 75 | dismiss(animated: true, completion: unitTestCompletion) 76 | } 77 | 78 | @objc func tapAction() { 79 | Current.analytics.recordActionTap(identifier: viewModel?.identifier ?? unknownIdentifier) 80 | SoundPlayer.playRandomApplause() 81 | dismiss(animated: true, completion: { [weak self] in 82 | self?.viewModel?.action() 83 | self?.unitTestCompletion?() 84 | }) 85 | } 86 | 87 | @objc func tapCancel() { 88 | Current.analytics.recordCancelTap(identifier: viewModel?.identifier ?? unknownIdentifier) 89 | dismiss(animated: true, completion: unitTestCompletion) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Conjugar/ResultsUIV.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ResultsUIV.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 8/7/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class ResultsUIV: UIView { 12 | @UsesAutoLayout 13 | var table: UITableView = { 14 | let tableView = UITableView() 15 | tableView.backgroundColor = Colors.black 16 | tableView.rowHeight = 120 17 | return tableView 18 | }() 19 | 20 | @UsesAutoLayout var difficulty = UILabel() 21 | @UsesAutoLayout var region = UILabel() 22 | @UsesAutoLayout var score = UILabel() 23 | @UsesAutoLayout var time = UILabel() 24 | @UsesAutoLayout var scoreLabel = UILabel() 25 | @UsesAutoLayout var timeLabel = UILabel() 26 | 27 | required init(coder aDecoder: NSCoder) { 28 | NSCoder.fatalErrorNotImplemented() 29 | } 30 | 31 | override init(frame: CGRect) { 32 | super.init(frame: frame) 33 | [difficulty, region, score, time, scoreLabel, timeLabel].forEach { 34 | $0.textColor = Colors.yellow 35 | $0.font = Fonts.label 36 | } 37 | [table, difficulty, region, score, time, scoreLabel, timeLabel].forEach { 38 | addSubview($0) 39 | } 40 | [(scoreLabel, Localizations.score + ":"), (timeLabel, Localizations.Results.time + ":")].forEach { 41 | $0.0.text = $0.1 42 | } 43 | 44 | NSLayoutConstraint.activate([ 45 | table.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), 46 | table.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), 47 | table.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), 48 | table.bottomAnchor.constraint(equalTo: difficulty.topAnchor, constant: Layout.defaultSpacing * -1.0), 49 | 50 | difficulty.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), 51 | difficulty.bottomAnchor.constraint(equalTo: score.topAnchor, constant: Layout.defaultSpacing * -1.0), 52 | 53 | region.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), 54 | region.bottomAnchor.constraint(equalTo: time.topAnchor, constant: Layout.defaultSpacing * -1.0), 55 | 56 | scoreLabel.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), 57 | scoreLabel.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -1.0 * Layout.defaultSpacing), 58 | 59 | timeLabel.trailingAnchor.constraint(equalTo: time.leadingAnchor, constant: Layout.defaultSpacing * -1.0), 60 | timeLabel.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -1.0 * Layout.defaultSpacing), 61 | 62 | time.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), 63 | time.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -1.0 * Layout.defaultSpacing), 64 | 65 | score.leadingAnchor.constraint(equalTo: scoreLabel.trailingAnchor, constant: Layout.defaultSpacing), 66 | score.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -1.0 * Layout.defaultSpacing), 67 | 68 | time.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), 69 | time.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -1.0 * Layout.defaultSpacing) 70 | ]) 71 | } 72 | 73 | func setupTable(dataSource: UITableViewDataSource, delegate: UITableViewDelegate) { 74 | table.dataSource = dataSource 75 | table.delegate = delegate 76 | table.register(ResultCell.self, forCellReuseIdentifier: ResultCell.identifier) 77 | } 78 | 79 | func reloadTableData() { 80 | table.reloadData() 81 | table.setContentOffset(CGPoint.zero, animated: false) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Conjugar/VerbUIV.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VerbUIV.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 7/18/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class VerbUIV: UIView { 12 | @UsesAutoLayout var translation = UILabel() 13 | @UsesAutoLayout var parentOrType = UILabel() 14 | @UsesAutoLayout var participio = UILabel() 15 | @UsesAutoLayout var gerundio = UILabel() 16 | @UsesAutoLayout var raízFutura = UILabel() 17 | @UsesAutoLayout var defectuoso = UILabel() 18 | @UsesAutoLayout private var raízFuturaLabel = UILabel() 19 | 20 | @UsesAutoLayout 21 | var table: UITableView = { 22 | let tableView = UITableView() 23 | tableView.backgroundColor = Colors.black 24 | return tableView 25 | }() 26 | 27 | required init(coder aDecoder: NSCoder) { 28 | NSCoder.fatalErrorNotImplemented() 29 | } 30 | 31 | override init(frame: CGRect) { 32 | super.init(frame: frame) 33 | [translation, parentOrType, participio, gerundio, raízFuturaLabel, raízFutura, defectuoso].forEach { 34 | $0.font = Fonts.label 35 | $0.textColor = Colors.yellow 36 | } 37 | raízFuturaLabel.text = "RF:" 38 | 39 | [translation, participio, gerundio, raízFutura, defectuoso].forEach { 40 | $0.isUserInteractionEnabled = true 41 | } 42 | 43 | [table, translation, parentOrType, participio, gerundio, raízFuturaLabel, raízFutura, defectuoso].forEach { 44 | addSubview($0) 45 | } 46 | 47 | NSLayoutConstraint.activate([ 48 | translation.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: Layout.defaultSpacing), 49 | translation.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), 50 | 51 | parentOrType.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: Layout.defaultSpacing), 52 | parentOrType.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), 53 | 54 | participio.topAnchor.constraint(equalTo: translation.bottomAnchor, constant: Layout.defaultSpacing), 55 | participio.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), 56 | 57 | gerundio.topAnchor.constraint(equalTo: parentOrType.bottomAnchor, constant: Layout.defaultSpacing), 58 | gerundio.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), 59 | 60 | raízFuturaLabel.topAnchor.constraint(equalTo: participio.bottomAnchor, constant: Layout.defaultSpacing), 61 | raízFuturaLabel.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), 62 | 63 | raízFutura.topAnchor.constraint(equalTo: participio.bottomAnchor, constant: Layout.defaultSpacing), 64 | raízFutura.leadingAnchor.constraint(equalTo: raízFuturaLabel.trailingAnchor, constant: Layout.defaultSpacing), 65 | 66 | defectuoso.topAnchor.constraint(equalTo: gerundio.bottomAnchor, constant: Layout.defaultSpacing), 67 | defectuoso.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), 68 | 69 | table.topAnchor.constraint(equalTo: raízFuturaLabel.bottomAnchor, constant: Layout.defaultSpacing), 70 | table.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), 71 | table.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), 72 | table.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -1.0 * Layout.defaultSpacing) 73 | ]) 74 | } 75 | 76 | func setupTable(dataSource: UITableViewDataSource, delegate: UITableViewDelegate) { 77 | table.dataSource = dataSource 78 | table.delegate = delegate 79 | table.register(TenseCell.self, forCellReuseIdentifier: TenseCell.identifier) 80 | table.register(ConjugationCell.self, forCellReuseIdentifier: ConjugationCell.identifier) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Conjugar/ConjugationDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConjugationDataSource.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 1/15/18. 6 | // Copyright © 2018 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | enum ConjugationRow { 12 | case tense(Tense) 13 | case conjugation(Tense, PersonNumber, String) 14 | } 15 | 16 | class ConjugationDataSource: NSObject, UITableViewDataSource, UITableViewDelegate { 17 | let rowCount: Int 18 | let verb: String 19 | weak var table: UITableView? 20 | var rows: [ConjugationRow] = [] 21 | 22 | init(verb: String, table: UITableView, secondSingularBrowse: SecondSingularBrowse) { 23 | self.verb = verb 24 | self.table = table 25 | let tenses = Tense.conjugatedTenses 26 | rowCount = tenses.reduce(0, { $0 + $1.conjugationCount(secondSingularBrowse: secondSingularBrowse) }) + tenses.count 27 | super.init() 28 | tenses.forEach { tense in 29 | self.rows.append(.tense(tense)) 30 | if tense.hasYoForm { 31 | let yoResult = Conjugator.shared.conjugate(infinitive: verb, tense: tense, personNumber: .firstSingular) 32 | switch yoResult { 33 | case let .success(value): 34 | self.rows.append(.conjugation(tense, .firstSingular, value)) 35 | default: 36 | fatalError("No yo form found for tense \(tense.displayName).") 37 | } 38 | } 39 | 40 | let tuResult = Conjugator.shared.conjugate(infinitive: verb, tense: tense, personNumber: .secondSingularTú) 41 | let tuConjugation: String 42 | switch tuResult { 43 | case let .success(value): 44 | tuConjugation = value 45 | default: 46 | fatalError("No tú form found for tense \(tense.displayName).") 47 | } 48 | let vosResult = Conjugator.shared.conjugate(infinitive: verb, tense: tense, personNumber: .secondSingularVos) 49 | let vosConjugation: String 50 | switch vosResult { 51 | case let .success(value): 52 | vosConjugation = value 53 | default: 54 | fatalError("No vos form found for tense \(tense.displayName).") 55 | } 56 | switch secondSingularBrowse { 57 | case .tu: 58 | self.rows.append(.conjugation(tense, .secondSingularTú, tuConjugation)) 59 | case .vos: 60 | self.rows.append(.conjugation(tense, .secondSingularVos, vosConjugation)) 61 | case .both: 62 | self.rows.append(.conjugation(tense, .secondSingularTú, tuConjugation)) 63 | self.rows.append(.conjugation(tense, .secondSingularVos, vosConjugation)) 64 | } 65 | [PersonNumber.thirdSingular, .firstPlural, .secondPlural, .thirdPlural].forEach { personNumber in 66 | let result = Conjugator.shared.conjugate(infinitive: verb, tense: tense, personNumber: personNumber) 67 | switch result { 68 | case let .success(value): 69 | self.rows.append(.conjugation(tense, personNumber, value)) 70 | default: 71 | fatalError("No \(personNumber.pronoun) form found.") 72 | } 73 | } 74 | } 75 | } 76 | 77 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 78 | return rowCount 79 | } 80 | 81 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 82 | switch rows[indexPath.row] { 83 | case .tense(let tense): 84 | guard let cell = table?.dequeueReusableCell(withIdentifier: TenseCell.identifier) as? TenseCell else { 85 | fatalError("Failed to dequeue cell for tense \(tense).") 86 | } 87 | cell.configure(tense: tense.titleCaseName) 88 | return cell 89 | case .conjugation(let tense, let personNumber, let conjugation): 90 | guard let cell = table?.dequeueReusableCell(withIdentifier: ConjugationCell.identifier) as? ConjugationCell else { 91 | fatalError("Failed to dequeue cell for tense \(tense), personNumber \(personNumber), and conjugation \(conjugation).") 92 | } 93 | cell.configure(tense: tense, personNumber: personNumber, conjugation: conjugation) 94 | return cell 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Conjugar/AnalyticsServiceable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnalyticsServiceable.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 11/24/18. 6 | // Copyright © 2018 Josh Adams. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | protocol AnalyticsServiceable { 13 | func recordEvent(_ eventName: String, parameters: [String: String]?, metrics: [String: Double]?) 14 | func recordEvent(_ eventName: String) 15 | func recordVisitation(viewController: String) 16 | func recordCommunVisitation(identifier: Int) 17 | func recordOkayTap(identifier: Int) 18 | func recordActionTap(identifier: Int) 19 | func recordCancelTap(identifier: Int) 20 | func recordQuizStart() 21 | func recordQuizCompletion(score: Int) 22 | func recordGameCenterAuth() 23 | func recordBecameActive() 24 | 25 | var visited: String { get } 26 | var viewContröller: String { get } 27 | var quizStart: String { get } 28 | var quizCompletion: String { get } 29 | var scöre: String { get } 30 | var gameCenterAuth: String { get } 31 | } 32 | 33 | extension AnalyticsServiceable { 34 | func recordEvent(_ eventName: String) { 35 | recordEvent(eventName, parameters: nil, metrics: nil) 36 | } 37 | 38 | func recordVisitation(viewController: String) { 39 | recordEvent(visited, parameters: [viewContröller: "\(viewController)"], metrics: nil) 40 | } 41 | 42 | func recordCommunVisitation(identifier: Int) { 43 | recordVisitation(viewController: "\(CommunVC.self) \(identifier)") 44 | } 45 | 46 | func recordOkayTap(identifier: Int) { 47 | recordEvent(okayTapped, parameters: [identifīer: "\(identifier)"], metrics: nil) 48 | } 49 | 50 | func recordActionTap(identifier: Int) { 51 | recordEvent(actionTapped, parameters: [identifīer: "\(identifier)"], metrics: nil) 52 | } 53 | 54 | func recordCancelTap(identifier: Int) { 55 | recordEvent(cancelTapped, parameters: [identifīer: "\(identifier)"], metrics: nil) 56 | } 57 | 58 | func recordCloseTap(identifier: Int) { 59 | recordEvent(closeTapped, parameters: [identifīer: "\(identifier)"], metrics: nil) 60 | } 61 | 62 | func recordQuizStart() { 63 | recordEvent(quizStart) 64 | } 65 | 66 | func recordQuizCompletion(score: Int) { 67 | recordEvent(quizCompletion, parameters: [scöre: "\(score)"], metrics: nil) 68 | } 69 | 70 | func recordQuizQuit(currentQuestionIndex: Int, score: Int) { 71 | recordEvent(quizQuit, parameters: [cürrentQuestionIndex: "\(currentQuestionIndex)", scöre: "\(score)"], metrics: nil) 72 | } 73 | 74 | func recordGameCenterAuth() { 75 | recordEvent(gameCenterAuth) 76 | } 77 | 78 | func recordBecameActive() { 79 | let becameActive = "becameActive" 80 | let modelKey = "model" 81 | let localeKey = "locale" 82 | 83 | let modelName = UIDevice.current.modelName 84 | let locale = Current.locale.locale 85 | 86 | recordEvent(becameActive, parameters: [modelKey: modelName, localeKey: locale], metrics: nil) 87 | } 88 | 89 | var visited: String { 90 | return "visited" 91 | } 92 | 93 | var viewContröller: String { 94 | return "viewController" 95 | } 96 | 97 | var okayTapped: String { 98 | return "okayTapped" 99 | } 100 | 101 | var actionTapped: String { 102 | return "actionTapped" 103 | } 104 | 105 | var cancelTapped: String { 106 | return "cancelTapped" 107 | } 108 | 109 | var closeTapped: String { 110 | return "closeTapped" 111 | } 112 | 113 | var identifīer: String { 114 | return "identifier" 115 | } 116 | 117 | var quizStart: String { 118 | return "quizStart" 119 | } 120 | 121 | var quizQuit: String { 122 | return "quizQuit" 123 | } 124 | 125 | var quizCompletion: String { 126 | return "quizCompletion" 127 | } 128 | 129 | var scöre: String { 130 | return "score" 131 | } 132 | 133 | var cürrentQuestionIndex: String { 134 | return "currentQuestionIndex" 135 | } 136 | 137 | var gameCenterAuth: String { 138 | return "gameCenterAuth" 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /ConjugarTests/Models/TenseTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TenseTests.swift 3 | // ConjugarTests 4 | // 5 | // Created by Joshua Adams on 5/14/19. 6 | // Copyright © 2019 Josh Adams. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Conjugar 11 | 12 | class TenseTests: XCTestCase { 13 | func testDisplayNames() { 14 | [(Tense.infinitivo, "infinitivo"), (.translation, "translation"), (.gerundio, "gerundio"), (.participio, "participio"), (.raízFutura, "raíz futura"), (.imperativoPositivo, "imperativo positivo"), (.imperativoNegativo, "imperativo negativo"), (.presenteDeIndicativo, "presente de indicativo"), (.pretérito, "pretérito"), (.imperfectoDeIndicativo, "imperfecto de indicativo"), (.futuroDeIndicativo, "futuro de indicativo"), (.condicional, "condicional"), (.presenteDeSubjuntivo, "presente de subjuntivo"), (.imperfectoDeSubjuntivo1, "imperfecto de subjuntivo 1"), (.imperfectoDeSubjuntivo2, "imperfecto de subjuntivo 2"), (.futuroDeSubjuntivo, "futuro de subjuntivo"), (.perfectoDeIndicativo, "perfecto de indicativo"), (.pretéritoAnterior, "pretérito anterior"), (.pluscuamperfectoDeIndicativo, "pluscuamperfecto de indicativo"), (.futuroPerfecto, "futuro perfecto"), (.condicionalCompuesto, "condicional compuesto"), (.perfectoDeSubjuntivo, "perfecto de subjuntivo"), (.pluscuamperfectoDeSubjuntivo1, "pluscuamperfecto de subjuntivo 1"), (.pluscuamperfectoDeSubjuntivo2, "pluscuamperfecto de subjuntivo 2")].forEach { 15 | testDisplayName(tense: $0.0, displayName: $0.1) 16 | } 17 | } 18 | 19 | private func testDisplayName(tense: Tense, displayName: String) { 20 | XCTAssertEqual(tense.displayName, displayName) 21 | } 22 | 23 | func testTitleCaseNames() { 24 | [(Tense.infinitivo, "Infinitivo"), (.translation, "Translation"), (.gerundio, "Gerundio"), (.participio, "Participio"), (.raízFutura, "Raíz Futura"), (.imperativoPositivo, "Imperativo Positivo"), (.imperativoNegativo, "Imperativo Negativo"), (.presenteDeIndicativo, "Presente de Indicativo"), (.pretérito, "Pretérito"), (.imperfectoDeIndicativo, "Imperfecto de Indicativo"), (.futuroDeIndicativo, "Futuro de Indicativo"), (.condicional, "Condicional"), (.presenteDeSubjuntivo, "Presente de Subjuntivo"), (.imperfectoDeSubjuntivo1, "Imperfecto de Subjuntivo 1"), (.imperfectoDeSubjuntivo2, "Imperfecto de Subjuntivo 2"), (.futuroDeSubjuntivo, "Futuro de Subjuntivo"), (.perfectoDeIndicativo, "Perfecto de Indicativo"), (.pretéritoAnterior, "Pretérito Anterior"), (.pluscuamperfectoDeIndicativo, "Pluscuamperfecto de Indicativo"), (.futuroPerfecto, "Futuro Perfecto"), (.condicionalCompuesto, "Condicional Compuesto"), (.perfectoDeSubjuntivo, "Perfecto de Subjuntivo"), (.pluscuamperfectoDeSubjuntivo1, "Pluscuamperfecto de Subjuntivo 1"), (.pluscuamperfectoDeSubjuntivo2, "Pluscuamperfecto de Subjuntivo 2")].forEach { 25 | testTitleCaseName(tense: $0.0, titleCaseName: $0.1) 26 | } 27 | } 28 | 29 | private func testTitleCaseName(tense: Tense, titleCaseName: String) { 30 | XCTAssertEqual(tense.titleCaseName, titleCaseName) 31 | } 32 | 33 | func testHaberTensesForCompoundTenses() { 34 | // The following line uses as much type inference as the compiler allows. 35 | [(Tense.perfectoDeIndicativo, .presenteDeIndicativo), (.pretéritoAnterior, .pretérito), (.pluscuamperfectoDeIndicativo, .imperfectoDeIndicativo), (.futuroPerfecto, .futuroDeIndicativo), (.condicionalCompuesto, .condicional), (.perfectoDeSubjuntivo, .presenteDeSubjuntivo), (.pluscuamperfectoDeSubjuntivo1, .imperfectoDeSubjuntivo1), (.pluscuamperfectoDeSubjuntivo2, .imperfectoDeSubjuntivo1), (Tense.futuroPerfectoDeSubjuntivo, Tense.futuroDeSubjuntivo)].forEach { 36 | testHaberTenseForCompoundTense(compoundTense: $0.0, haberTense: $0.1) 37 | } 38 | 39 | let notACompoundTense: Tense = .infinitivo 40 | let result = notACompoundTense.haberTenseForCompoundTense() 41 | switch result { 42 | case .success(let haberTense): 43 | XCTFail("Haber form \(haberTense.displayName) incorrectly found for tense \(notACompoundTense.displayName).") 44 | case .failure: 45 | break 46 | } 47 | } 48 | 49 | private func testHaberTenseForCompoundTense(compoundTense: Tense, haberTense: Tense) { 50 | let result = compoundTense.haberTenseForCompoundTense() 51 | switch result { 52 | case .success(let haberTense): 53 | XCTAssertEqual(haberTense, haberTense) 54 | case .failure: 55 | XCTFail("No haber tense found for \(compoundTense.displayName).") 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Conjugar/BrowseInfoVC.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BrowseInfoVC.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 7/1/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class BrowseInfoVC: UIViewController, UITableViewDelegate, UITableViewDataSource, InfoDelegate { 12 | static let englishTitle = "Info" 13 | 14 | private var selectedRow = 0 15 | private var allInfos: [Info] = [] 16 | private var easyModerateInfos: [Info] = [] 17 | private var easyInfos: [Info] = [] 18 | 19 | private var currentInfos: [Info] { 20 | switch browseInfoView.difficultyControl.selectedSegmentIndex { 21 | case 0: 22 | return easyInfos 23 | case 1: 24 | return easyModerateInfos 25 | case 2: 26 | return allInfos 27 | default: 28 | fatalError("Invalid UISegmentedControl index.") 29 | } 30 | } 31 | 32 | var browseInfoView: BrowseInfoUIV { 33 | if let castedView = view as? BrowseInfoUIV { 34 | return castedView 35 | } else { 36 | fatalError(fatalCastMessage(view: BrowseInfoUIV.self)) 37 | } 38 | } 39 | 40 | override func loadView() { 41 | let browseInfoView: BrowseInfoUIV 42 | browseInfoView = BrowseInfoUIV(frame: UIScreen.main.bounds) 43 | browseInfoView.difficultyControl.addTarget(self, action: #selector(BrowseInfoVC.difficultyChanged(_:)), for: .valueChanged) 44 | browseInfoView.setupTable(dataSource: self, delegate: self) 45 | navigationItem.titleView = UILabel.titleLabel(title: Localizations.BrowseInfo.localizedTitle) 46 | easyInfos = Info.infos.filter { 47 | $0.difficulty == .easy 48 | } 49 | easyModerateInfos = Info.infos.filter { 50 | $0.difficulty == .easy || $0.difficulty == .moderate 51 | } 52 | allInfos = Info.infos 53 | view = browseInfoView 54 | } 55 | 56 | override func viewDidLoad() { 57 | super.viewDidLoad() 58 | updateDifficultyControl() 59 | } 60 | 61 | override func viewWillAppear(_ animated: Bool) { 62 | super.viewWillAppear(animated) 63 | Current.analytics.recordVisitation(viewController: "\(BrowseInfoVC.self)") 64 | } 65 | 66 | private func updateDifficultyControl() { 67 | switch Current.settings.infoDifficulty { 68 | case .easy: 69 | browseInfoView.difficultyControl.selectedSegmentIndex = 0 70 | case .moderate: 71 | browseInfoView.difficultyControl.selectedSegmentIndex = 1 72 | case .difficult: 73 | browseInfoView.difficultyControl.selectedSegmentIndex = 2 74 | } 75 | } 76 | 77 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 78 | return currentInfos.count 79 | } 80 | 81 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 82 | guard let cell = tableView.dequeueReusableCell(withIdentifier: InfoCell.identifier) as? InfoCell else { 83 | fatalError("Could not dequeue \(InfoCell.self).") 84 | } 85 | guard let decodedString = currentInfos[indexPath.row].heading.removingPercentEncoding else { 86 | fatalError("Could not decode string.") 87 | } 88 | cell.configure(heading: decodedString) 89 | return cell 90 | } 91 | 92 | func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 93 | selectedRow = (indexPath as NSIndexPath).row 94 | tableView.deselectRow(at: indexPath, animated: false) 95 | showInfo() 96 | } 97 | 98 | func infoSelectionDidChange(newHeading: String) { 99 | for i in 0 ..< Info.infos.count { 100 | if currentInfos[i].heading.lowercased() == newHeading.lowercased() { 101 | selectedRow = i 102 | break 103 | } 104 | } 105 | showInfo() 106 | } 107 | 108 | private func showInfo() { 109 | let infoVC = InfoVC(infoString: currentInfos[selectedRow].infoString, infoDelegate: self) 110 | navigationController?.pushViewController(infoVC, animated: true) 111 | } 112 | 113 | @objc func difficultyChanged(_ sender: UISegmentedControl) { 114 | let index = browseInfoView.difficultyControl.selectedSegmentIndex 115 | if index == 0 { 116 | Current.settings.infoDifficulty = .easy 117 | } else if index == 1 { 118 | Current.settings.infoDifficulty = .moderate 119 | } else /* index == 2 */ { 120 | Current.settings.infoDifficulty = .difficult 121 | } 122 | browseInfoView.table.reloadData() 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Conjugar/World.swift: -------------------------------------------------------------------------------- 1 | // 2 | // World.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 1/15/19. 6 | // Enhanced by Stephen Celis on 1/16/19. 7 | // Copyright © 2019 Josh Adams. All rights reserved. 8 | // 9 | 10 | import Observation 11 | import SwiftUI 12 | 13 | #if targetEnvironment(simulator) 14 | var Current = World.simulator 15 | #else 16 | var Current = World.device 17 | #endif 18 | 19 | class World { 20 | var analytics: AnalyticsServiceable 21 | var reviewPrompter: ReviewPromptable 22 | var gameCenter: GameCenterable 23 | var settings: Settings 24 | var quiz: Quiz 25 | var session: URLSession 26 | var communGetter: CommunGetter 27 | var locale: Locale 28 | var parentViewController: UIViewController? 29 | 30 | private static let fakeRatingsCount = 42 31 | 32 | init( 33 | analytics: AnalyticsServiceable, 34 | reviewPrompter: ReviewPromptable, 35 | gameCenter: GameCenterable, 36 | settings: Settings, 37 | quiz: Quiz, 38 | session: URLSession, 39 | communGetter: CommunGetter, 40 | locale: Locale 41 | ) { 42 | self.analytics = analytics 43 | self.reviewPrompter = reviewPrompter 44 | self.gameCenter = gameCenter 45 | self.settings = settings 46 | self.quiz = quiz 47 | self.session = session 48 | self.communGetter = communGetter 49 | self.locale = locale 50 | } 51 | 52 | static let device: World = { 53 | let settings = Settings(getterSetter: UserDefaultsGetterSetter()) 54 | let gameCenter = GameCenter.shared 55 | 56 | return World( 57 | analytics: AWSAnalyticsService(), 58 | reviewPrompter: ReviewPrompter(), 59 | gameCenter: gameCenter, 60 | settings: settings, 61 | quiz: Quiz(settings: settings, gameCenter: gameCenter, shouldShuffle: true), 62 | session: URLSession.shared, 63 | communGetter: CloudCommunGetter(), 64 | locale: RealLocale() 65 | ) 66 | }() 67 | 68 | static let simulator: World = { 69 | let settings = Settings(getterSetter: UserDefaultsGetterSetter()) 70 | let gameCenter = TestGameCenter() 71 | 72 | return World( 73 | analytics: TestAnalyticsService(), 74 | reviewPrompter: TestReviewPrompter(), 75 | gameCenter: gameCenter, 76 | settings: settings, 77 | quiz: Quiz(settings: settings, gameCenter: gameCenter, shouldShuffle: true), 78 | session: URLSession.stubSession(ratingsCount: fakeRatingsCount), 79 | communGetter: StubCommunGetter(), 80 | locale: StubLocale(languageCode: "en", regionCode: "US") 81 | ) 82 | }() 83 | 84 | static let unitTest: World = { 85 | let settings = Settings(getterSetter: DictionaryGetterSetter()) 86 | let gameCenter = TestGameCenter() 87 | 88 | return World( 89 | analytics: TestAnalyticsService(), 90 | reviewPrompter: TestReviewPrompter(), 91 | gameCenter: gameCenter, 92 | settings: settings, 93 | quiz: Quiz(settings: settings, gameCenter: gameCenter, shouldShuffle: false), 94 | session: URLSession.stubSession(ratingsCount: fakeRatingsCount), 95 | communGetter: StubCommunGetter(), 96 | locale: StubLocale() 97 | ) 98 | }() 99 | 100 | static func uiTest(launchArguments arguments: [String]) -> World { 101 | let region: Region 102 | if arguments.contains(Region.spain.rawValue) { 103 | region = .spain 104 | } else if arguments.contains(Region.latinAmerica.rawValue) { 105 | region = .latinAmerica 106 | } else { 107 | region = Settings.regionDefault 108 | } 109 | 110 | let difficulty: Difficulty 111 | if arguments.contains(Difficulty.difficult.rawValue) { 112 | difficulty = .difficult 113 | } else if arguments.contains(Difficulty.moderate.rawValue) { 114 | difficulty = .moderate 115 | } else if arguments.contains(Difficulty.easy.rawValue) { 116 | difficulty = .easy 117 | } else { 118 | difficulty = Settings.difficultyDefault 119 | } 120 | 121 | let dictionary = [Settings.regionKey: region.rawValue, Settings.difficultyKey: difficulty.rawValue] 122 | let settings = Settings(getterSetter: DictionaryGetterSetter(dictionary: dictionary)) 123 | let gameCenter = TestGameCenter() 124 | 125 | return World( 126 | analytics: TestAnalyticsService(), 127 | reviewPrompter: TestReviewPrompter(), 128 | gameCenter: gameCenter, 129 | settings: settings, 130 | quiz: Quiz(settings: settings, gameCenter: gameCenter, shouldShuffle: false), 131 | session: URLSession.stubSession(ratingsCount: fakeRatingsCount), 132 | communGetter: StubCommunGetter(), 133 | locale: StubLocale() 134 | ) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /Conjugar.xcodeproj/xcshareddata/xcschemes/Conjugar.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 38 | 39 | 40 | 41 | 44 | 50 | 51 | 52 | 55 | 61 | 62 | 63 | 64 | 65 | 75 | 77 | 83 | 84 | 85 | 86 | 90 | 91 | 92 | 93 | 99 | 101 | 107 | 108 | 109 | 110 | 112 | 113 | 116 | 117 | 118 | -------------------------------------------------------------------------------- /Conjugar/VerbVC.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VerbVC.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 4/10/17. 6 | // Copyright © 2017 Josh Adams. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class VerbVC: UIViewController { 12 | private let verb: String 13 | private var conjugationDataSource: ConjugationDataSource? 14 | 15 | var verbView: VerbUIV { 16 | if let castedView = view as? VerbUIV { 17 | return castedView 18 | } else { 19 | fatalError(fatalCastMessage(view: VerbUIV.self)) 20 | } 21 | } 22 | 23 | init(verb: String) { 24 | self.verb = verb 25 | super.init(nibName: nil, bundle: nil) 26 | } 27 | 28 | required init?(coder aDecoder: NSCoder) { 29 | NSCoder.fatalErrorNotImplemented() 30 | } 31 | 32 | override func loadView() { 33 | let verbView = VerbUIV(frame: UIScreen.main.bounds) 34 | verbView.participio.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tapSpanish(_:)))) 35 | verbView.raízFutura.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tapSpanish(_:)))) 36 | verbView.gerundio.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tapSpanish(_:)))) 37 | verbView.translation.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tapEnglish(_:)))) 38 | verbView.defectuoso.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tapEnglish(_:)))) 39 | initNavigationItemTitleView() 40 | let translationResult = Conjugator.shared.conjugate(infinitive: verb, tense: .translation, personNumber: .none) 41 | switch translationResult { 42 | case let .success(value): 43 | verbView.translation.text = value 44 | default: 45 | fatalError() 46 | } 47 | let gerundioResult = Conjugator.shared.conjugate(infinitive: verb, tense: .gerundio, personNumber: .none) 48 | switch gerundioResult { 49 | case let .success(value): 50 | verbView.gerundio.attributedText = value.conjugatedString 51 | default: 52 | fatalError() 53 | } 54 | let participioResult = Conjugator.shared.conjugate(infinitive: verb, tense: .participio, personNumber: .none) 55 | switch participioResult { 56 | case let .success(value): 57 | verbView.participio.attributedText = value.conjugatedString 58 | default: 59 | fatalError() 60 | } 61 | let raízFuturaResult = Conjugator.shared.conjugate(infinitive: verb, tense: .raízFutura, personNumber: .none) 62 | switch raízFuturaResult { 63 | case let .success(value): 64 | verbView.raízFutura.attributedText = value.conjugatedString + NSAttributedString(string: "-") 65 | default: 66 | fatalError() 67 | } 68 | if Conjugator.shared.isDefective(infinitive: verb) { 69 | verbView.defectuoso.text = Localizations.Verb.defective 70 | } else { 71 | verbView.defectuoso.text = Localizations.Verb.notDefective 72 | } 73 | 74 | let verbType = Conjugator.shared.verbType(infinitive: verb) 75 | switch verbType { 76 | case .regularAr: 77 | verbView.parentOrType.text = "\(Localizations.Verb.regular) AR" 78 | case .regularEr: 79 | verbView.parentOrType.text = "\(Localizations.Verb.regular) ER" 80 | case .regularIr: 81 | verbView.parentOrType.text = "\(Localizations.Verb.regular) IR" 82 | case .irregular: 83 | guard let parent = Conjugator.shared.parent(infinitive: verb) else { 84 | fatalError("Parent verb not found.") 85 | } 86 | if Conjugator.baseVerbs.contains(parent) { 87 | verbView.parentOrType.text = Localizations.Verb.irregular 88 | } else { 89 | verbView.parentOrType.text = String(format: Localizations.Verb.irregularWithParent, parent) 90 | } 91 | } 92 | view = verbView 93 | } 94 | 95 | override func viewWillAppear(_ animated: Bool) { 96 | super.viewWillAppear(animated) 97 | conjugationDataSource = ConjugationDataSource(verb: verb, table: verbView.table, secondSingularBrowse: Current.settings.secondSingularBrowse) 98 | guard let conjugationDataSource = conjugationDataSource else { 99 | fatalError("\(ConjugationDataSource.self) was nil.") 100 | } 101 | verbView.setupTable(dataSource: conjugationDataSource, delegate: conjugationDataSource) 102 | verbView.table.reloadData() 103 | Current.analytics.recordVisitation(viewController: "\(VerbVC.self)") 104 | } 105 | 106 | private func initNavigationItemTitleView() { 107 | let titleLabel = UILabel.titleLabel(title: verb.capitalized) 108 | navigationItem.titleView = titleLabel 109 | titleLabel.isUserInteractionEnabled = true 110 | titleLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapSpanish(_:)))) 111 | } 112 | 113 | @objc func tapSpanish(_ sender: UITapGestureRecognizer) { 114 | if let label = sender.view as? UILabel { 115 | Utterer.utter(label.attributedText?.string ?? label.text ?? "") 116 | } 117 | } 118 | 119 | @objc func tapEnglish(_ sender: UITapGestureRecognizer) { 120 | if let label = sender.view as? UILabel { 121 | Utterer.utter(label.attributedText?.string ?? label.text ?? "", locale: "en-US") 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /ConjugarUITests/QuizVCUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QuizVCUITests.swift 3 | // ConjugarUITests 4 | // 5 | // Created by Joshua Adams on 12/8/18. 6 | // Copyright © 2018 Josh Adams. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | class QuizVCUITests: XCTestCase { 12 | let enableUiTesting = "enable-ui-testing" 13 | 14 | override func setUp() { 15 | continueAfterFailure = false 16 | } 17 | 18 | override func tearDown() {} 19 | 20 | func testDifficultSpainQuiz() { 21 | testQuiz(answers: ["sintiendo", "midiendo", "caminando", "existiendo", "bebiendo", "andas", "ocurre", "leemos", "vais", "duermen", "hago", "fuiste", "dio", "pusimos", "trabajastéis", "recibieron", "aprendí", "ibas", "veía", "caminábamos", "andabais", "sabrán", "cabré", "trabajarás", "estudiará", "escucharíamos", "visitaríais", "podrían", "vaya", "hayas", "sepa", "estudiemos", "permitáis", "comprendan", "pudiera", "viajases", "estuviere", "enseñáremos", "ve", "llevad", "no llegen", "he cubierto", "hubiste bailado", "había dicho", "habremos nadado", "habríais escrito", "hayan cocinado", "hubiera hecho", "hubieses charlado", "hubiere muerto"], region: "Spain", difficulty: "Difficult") 22 | } 23 | 24 | func testDifficultLatinAmericaQuiz() { 25 | testQuiz(answers: ["sintiendo", "midiendo", "caminando", "existiendo", "bebiendo", "andas", "ocurre", "leemos", "van", "duermo", "haces", "fue", "dimos", "pusieron", "trabajé", "recibiste", "aprendió", "ibamos", "veían", "caminaba", "andabas", "sabrá", "cabremos", "trabajarán", "estudiaré", "escucharías", "visitaría", "podríamos", "vayan", "haya", "sepas", "estudie", "permitamos", "comprendan", "pudiera", "viajases", "estuviere", "enseñáremos", "ve", "lleven", "no llege", "hemos cubierto", "hubieron bailado", "había dicho", "habrás nadado", "habría escrito", "hayamos cocinado", "hubieran hecho", "hubiese charlado", "hubieres muerto"], region: "Latin America", difficulty: "Difficult") 26 | } 27 | 28 | func testModerateSpainQuiz() { 29 | testQuiz(answers: ["caminas", "anda", "existimos", "bebéis", "van", "duermo", "haces", "muere", "sabremos", "cabréis", "podrán", "caminaré", "andarás", "querría", "pondríamos", "tendríais", "trabajarían", "estudiaría", "has cubierto", "ha dicho", "hemos escrito", "habéis escuchado", "han visitado", "iba", "veías", "era", "viajábamos", "enseñabais", "llevaban", "fui", "diste", "puso", "trabajamos", "ocurristeis", "leieron", "vaya", "hayas", "sepa", "llegemos", "bailéis", "sintiendo", "midiendo", "nadando", "cocinando", "ve", "he", "charlen", "platice", "no lloremos", "no esperéis"], region: "Spain", difficulty: "Moderate") 30 | } 31 | 32 | func testModerateLatinAmericaQuiz() { 33 | testQuiz(answers: ["caminas", "anda", "existimos", "beben", "voy", "duermes", "hace", "morimos", "sabrán", "cabré", "podrás", "caminará", "andaremos", "querrían", "pondría", "tendrías", "trabajaría", "estudiaríamos", "han cubierto", "he dicho", "has escrito", "ha escuchado", "hemos visitado", "iban", "veía", "eras", "viajaba", "enseñábamos", "llevaban", "fui", "diste", "puso", "trabajamos", "ocurrieron", "leí", "vayas", "haya", "sepamos", "llegen", "baile", "sintiendo", "midiendo", "nadando", "cocinando", "ve", "he", "charle", "platicemos", "no lloren", "no espere"], region: "Latin America", difficulty: "Moderate") 34 | } 35 | 36 | func testEasySpainQuiz() { 37 | testQuiz(answers: ["caminas", "anda", "trabajamos", "existís", "ocurren", "recibo", "bebes", "lee", "aprendemos", "vais", "duermen", "hago", "mueres", "muerde", "oímos", "podéis", "han", "siento", "sabrás", "cabrá", "podremos", "querréis", "pondrán", "tendré", "vendrás", "saldrá", "estudiaremos", "escucharéis", "visitarán", "permitiré", "partirás", "comprenderá", "correremos", "fuisteis", "dieron", "puse", "pudiste", "estuvo", "tuvimos", "anduvisteis", "supieron", "caminé", "anduviste", "trabajó", "estudiamos", "escuchastéis", "visitaron", "viajé", "enseñaste", "llevó"], region: "Spain", difficulty: "Easy") 38 | } 39 | 40 | func testEasyLatinAmericaQuiz() { 41 | testQuiz(answers: ["caminas", "anda", "trabajamos", "existen", "ocurro", "recibes", "bebe", "leemos", "aprenden", "voy", "duermes", "hace", "morimos", "muerden", "oigo", "puedes", "ha", "sentimos", "sabrán", "cabré", "podrás", "querrá", "pondremos", "tendrán", "vendré", "saldrás", "estudiará", "escucharemos", "visitarán", "permitiré", "partirás", "comprenderá", "correremos", "fueron", "di", "pusiste", "pudo", "estuvimos", "tuvieron", "anduve", "supiste", "caminó", "anduvimos", "trabajaron", "estudié", "escuchaste", "visitó", "viajamos", "enseñaron", "llevé"], region: "Latin America", difficulty: "Easy") 42 | } 43 | 44 | func testQuiz(answers: [String], region: String, difficulty: String) { 45 | let app = XCUIApplication() 46 | app.launchArguments = [enableUiTesting, region, difficulty] 47 | app.launch() 48 | app.tabBars.buttons["Quiz"].tap() 49 | let timeout: TimeInterval = 1.0 50 | XCTAssert(app.buttons["Start"].waitForExistence(timeout: timeout)) 51 | app.buttons["Start"].tap() 52 | let textField = app.textFields[" conjugation"] 53 | answers.forEach { conjugation in 54 | textField.typeText(conjugation + "\n") 55 | } 56 | XCTAssert(app.staticTexts["Results"].waitForExistence(timeout: timeout)) 57 | XCTAssert(app.staticTexts[region].exists) 58 | XCTAssert(app.staticTexts[difficulty].exists) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Conjugar/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | AvenirNext-DemiBold 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /Conjugar/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "20x20", 5 | "idiom" : "iphone", 6 | "filename" : "icon20@2x.png", 7 | "scale" : "2x" 8 | }, 9 | { 10 | "size" : "20x20", 11 | "idiom" : "iphone", 12 | "filename" : "icon20@2x-1.png", 13 | "language-direction" : "left-to-right", 14 | "scale" : "2x" 15 | }, 16 | { 17 | "size" : "20x20", 18 | "idiom" : "iphone", 19 | "filename" : "icon20@2x-2.png", 20 | "language-direction" : "right-to-left", 21 | "scale" : "2x" 22 | }, 23 | { 24 | "size" : "20x20", 25 | "idiom" : "iphone", 26 | "filename" : "icon20@3x.png", 27 | "scale" : "3x" 28 | }, 29 | { 30 | "size" : "20x20", 31 | "idiom" : "iphone", 32 | "filename" : "icon20@3x-2.png", 33 | "language-direction" : "left-to-right", 34 | "scale" : "3x" 35 | }, 36 | { 37 | "size" : "20x20", 38 | "idiom" : "iphone", 39 | "filename" : "icon20@3x-1.png", 40 | "language-direction" : "right-to-left", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "size" : "29x29", 45 | "idiom" : "iphone", 46 | "filename" : "icon29.png", 47 | "language-direction" : "left-to-right", 48 | "scale" : "1x" 49 | }, 50 | { 51 | "size" : "29x29", 52 | "idiom" : "iphone", 53 | "filename" : "icon29-1.png", 54 | "language-direction" : "right-to-left", 55 | "scale" : "1x" 56 | }, 57 | { 58 | "size" : "29x29", 59 | "idiom" : "iphone", 60 | "filename" : "icon29@2x.png", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "size" : "29x29", 65 | "idiom" : "iphone", 66 | "filename" : "icon29@2x-1.png", 67 | "language-direction" : "left-to-right", 68 | "scale" : "2x" 69 | }, 70 | { 71 | "size" : "29x29", 72 | "idiom" : "iphone", 73 | "filename" : "icon29@2x-2.png", 74 | "language-direction" : "right-to-left", 75 | "scale" : "2x" 76 | }, 77 | { 78 | "size" : "29x29", 79 | "idiom" : "iphone", 80 | "filename" : "icon29@3x.png", 81 | "scale" : "3x" 82 | }, 83 | { 84 | "size" : "29x29", 85 | "idiom" : "iphone", 86 | "filename" : "icon29@3x-1.png", 87 | "language-direction" : "left-to-right", 88 | "scale" : "3x" 89 | }, 90 | { 91 | "size" : "29x29", 92 | "idiom" : "iphone", 93 | "filename" : "icon29@3x-2.png", 94 | "language-direction" : "right-to-left", 95 | "scale" : "3x" 96 | }, 97 | { 98 | "size" : "40x40", 99 | "idiom" : "iphone", 100 | "filename" : "icon40@2x.png", 101 | "scale" : "2x" 102 | }, 103 | { 104 | "size" : "40x40", 105 | "idiom" : "iphone", 106 | "filename" : "icon40@2x-1.png", 107 | "language-direction" : "left-to-right", 108 | "scale" : "2x" 109 | }, 110 | { 111 | "size" : "40x40", 112 | "idiom" : "iphone", 113 | "filename" : "icon40@2x-2.png", 114 | "language-direction" : "right-to-left", 115 | "scale" : "2x" 116 | }, 117 | { 118 | "size" : "40x40", 119 | "idiom" : "iphone", 120 | "filename" : "icon40@3x.png", 121 | "scale" : "3x" 122 | }, 123 | { 124 | "size" : "40x40", 125 | "idiom" : "iphone", 126 | "filename" : "icon40@3x-1.png", 127 | "language-direction" : "left-to-right", 128 | "scale" : "3x" 129 | }, 130 | { 131 | "size" : "40x40", 132 | "idiom" : "iphone", 133 | "filename" : "icon40@3x-2.png", 134 | "language-direction" : "right-to-left", 135 | "scale" : "3x" 136 | }, 137 | { 138 | "size" : "57x57", 139 | "idiom" : "iphone", 140 | "language-direction" : "left-to-right", 141 | "scale" : "1x" 142 | }, 143 | { 144 | "size" : "57x57", 145 | "idiom" : "iphone", 146 | "language-direction" : "right-to-left", 147 | "scale" : "1x" 148 | }, 149 | { 150 | "size" : "57x57", 151 | "idiom" : "iphone", 152 | "language-direction" : "left-to-right", 153 | "scale" : "2x" 154 | }, 155 | { 156 | "size" : "57x57", 157 | "idiom" : "iphone", 158 | "language-direction" : "right-to-left", 159 | "scale" : "2x" 160 | }, 161 | { 162 | "size" : "60x60", 163 | "idiom" : "iphone", 164 | "filename" : "icon60@2x.png", 165 | "scale" : "2x" 166 | }, 167 | { 168 | "size" : "60x60", 169 | "idiom" : "iphone", 170 | "filename" : "icon60@2x-1.png", 171 | "language-direction" : "left-to-right", 172 | "scale" : "2x" 173 | }, 174 | { 175 | "size" : "60x60", 176 | "idiom" : "iphone", 177 | "filename" : "icon60@2x-2.png", 178 | "language-direction" : "right-to-left", 179 | "scale" : "2x" 180 | }, 181 | { 182 | "size" : "60x60", 183 | "idiom" : "iphone", 184 | "filename" : "icon60@3x.png", 185 | "scale" : "3x" 186 | }, 187 | { 188 | "size" : "60x60", 189 | "idiom" : "iphone", 190 | "filename" : "icon60@3x-1.png", 191 | "language-direction" : "left-to-right", 192 | "scale" : "3x" 193 | }, 194 | { 195 | "size" : "60x60", 196 | "idiom" : "iphone", 197 | "filename" : "icon60@3x-2.png", 198 | "language-direction" : "right-to-left", 199 | "scale" : "3x" 200 | }, 201 | { 202 | "size" : "1024x1024", 203 | "idiom" : "ios-marketing", 204 | "filename" : "icon1024.png", 205 | "scale" : "1x" 206 | } 207 | ], 208 | "info" : { 209 | "version" : 1, 210 | "author" : "xcode" 211 | } 212 | } -------------------------------------------------------------------------------- /Conjugar/CloudCommunGetter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CloudCommunGetter.swift 3 | // Conjugar 4 | // 5 | // Created by Joshua Adams on 12/18/20. 6 | // Copyright © 2020 Josh Adams. All rights reserved. 7 | // 8 | 9 | import CloudKit 10 | import MessageUI 11 | import UIKit 12 | 13 | struct CloudCommunGetter: CommunGetter { 14 | func getCommunication(completion: @escaping (Commun) -> Void) { 15 | let predicate = NSPredicate(format: "isCurrent == 1") 16 | let query = CKQuery(recordType: "Communs", predicate: predicate) 17 | let identifier = "iCloud.biz.Conjugar" 18 | 19 | // TODO: Perhaps use something like this to fix deprecation in code after this. 20 | // TODO: When done, reenable getCommunication() in MainTabBarVC. 21 | // CKContainer(identifier: identifier).publicCloudDatabase.fetch(withQuery: query) { result in 22 | // switch result { 23 | // case .success((let matchResults, let queryCursor)): 24 | // <#code#> 25 | // case .failure: 26 | // return 27 | // } 28 | // } 29 | 30 | CKContainer(identifier: identifier).publicCloudDatabase.perform(query, inZoneWith: nil) { records, error in 31 | guard 32 | error == nil, 33 | let records = records, 34 | let record = records.first 35 | else { 36 | return 37 | } 38 | 39 | let separator = "|" 40 | 41 | guard 42 | let titleString = record["title"] as? String, 43 | let titleDict = dictFromString(string: titleString, primarySeparator: separator), 44 | let contentString = record["content"] as? String, 45 | let contentDict = dictFromString(string: contentString, primarySeparator: separator), 46 | let okayTitleString = record["okayTitle"] as? String, 47 | let okayTitleDict = dictFromString(string: okayTitleString, primarySeparator: separator), 48 | let cancelTitleString = record["cancelTitle"] as? String, 49 | let cancelTitleDict = dictFromString(string: cancelTitleString, primarySeparator: separator), 50 | let actionTitleString = record["actionTitle"] as? String, 51 | let actionTitleDict = dictFromString(string: actionTitleString, primarySeparator: separator), 52 | let typeString = record["type"] as? String, 53 | let cloudSchemaVersion = record["version"] as? Int, 54 | let imageAsset = record["image"] as? CKAsset, 55 | let imageFileUrl = imageAsset.fileURL, 56 | let imageData = try? Data(contentsOf: imageFileUrl), 57 | let image = UIImage(data: imageData), 58 | let imageLabelString = record["imageLabel"] as? String, 59 | let imageLabelDict = dictFromString(string: imageLabelString, primarySeparator: separator), 60 | let identifier = record["identifier"] as? Int 61 | else { 62 | return 63 | } 64 | 65 | let typeElements = typeString.components(separatedBy: separator) 66 | let typeFirstElement = "\(typeElements[0])" 67 | 68 | var type: Commun.CommunType? 69 | switch typeFirstElement { 70 | case "newVersion": 71 | guard 72 | typeElements.count > 1, 73 | let cloudVersion = Double(typeElements[1]), 74 | let appVersionString = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String, 75 | let appVersion = Double(appVersionString), 76 | let openUrlClosure = openUrlClosure(urlString: "https://itunes.apple.com/\(Current.locale.regionCode)/app/conjugar/id1236500467") 77 | else { 78 | return 79 | } 80 | 81 | let alreadyUpdated = appVersion >= cloudVersion 82 | 83 | type = Commun.CommunType.newVersion( 84 | okayTitle: okayTitleDict, 85 | actionTitle: actionTitleDict, 86 | cancelTitle: cancelTitleDict, 87 | action: openUrlClosure, 88 | alreadyUpdated: alreadyUpdated 89 | ) 90 | 91 | case "website": 92 | guard 93 | typeElements.count > 1, 94 | let openUrlClosure = openUrlClosure(urlString: typeElements[1]) 95 | else { 96 | return 97 | } 98 | 99 | type = Commun.CommunType.website(actionTitle: actionTitleDict, cancelTitle: cancelTitleDict, action: openUrlClosure) 100 | 101 | case "information": 102 | type = Commun.CommunType.information(okayTitle: okayTitleDict) 103 | 104 | case "email": 105 | if let sendEmailClosure = Emailer.shared.sendEmailClosure { 106 | type = Commun.CommunType.email(actionTitle: actionTitleDict, cancelTitle: cancelTitleDict, action: sendEmailClosure) 107 | } 108 | 109 | default: 110 | break 111 | } 112 | 113 | let appSchemaVersion = 0 114 | if 115 | let type = type, 116 | appSchemaVersion >= cloudSchemaVersion 117 | { 118 | let commun = Commun(title: titleDict, image: image, imageLabel: imageLabelDict, content: contentDict, type: type, identifier: identifier) 119 | completion(commun) 120 | } 121 | } 122 | } 123 | 124 | private func dictFromString(string: String, primarySeparator: String) -> [String: String]? { 125 | guard !string.isEmpty else { 126 | return nil 127 | } 128 | let variants = string.components(separatedBy: primarySeparator) 129 | guard !variants.isEmpty else { 130 | return nil 131 | } 132 | var dict: [String: String] = [:] 133 | let secondarySeparator = "=" 134 | for variant in variants { 135 | let components = variant.components(separatedBy: secondarySeparator) 136 | guard 137 | components.count == 2, 138 | ["en", "es"].contains(components[0]) 139 | else { 140 | continue 141 | } 142 | dict[components[0]] = components[1] 143 | } 144 | 145 | if !dict.isEmpty { 146 | return dict 147 | } else { 148 | return nil 149 | } 150 | } 151 | } 152 | --------------------------------------------------------------------------------