├── Vocable ├── Supporting Files │ ├── de.lproj │ │ └── LaunchScreen.strings │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── Colors │ │ │ ├── Contents.json │ │ │ ├── ErrorRed.colorset │ │ │ │ └── Contents.json │ │ │ ├── Background.colorset │ │ │ │ └── Contents.json │ │ │ ├── GrayDivider.colorset │ │ │ │ └── Contents.json │ │ │ ├── Selection.colorset │ │ │ │ └── Contents.json │ │ │ ├── TextHighlight.colorset │ │ │ │ └── Contents.json │ │ │ ├── BorderHighlight.colorset │ │ │ │ └── Contents.json │ │ │ ├── CategoryBackground.colorset │ │ │ │ └── Contents.json │ │ │ ├── DefaultFontColor.colorset │ │ │ │ └── Contents.json │ │ │ ├── AlertBackground.colorset │ │ │ │ └── Contents.json │ │ │ ├── DefaultCellBackground.colorset │ │ │ │ └── Contents.json │ │ │ └── selectedCellBackground.colorset │ │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── icon-ios-20@2x.png │ │ │ ├── icon-ios-20@3x.png │ │ │ ├── icon-ios-29@2x.png │ │ │ ├── icon-ios-29@3x.png │ │ │ ├── icon-ios-40@2x.png │ │ │ ├── icon-ios-40@3x.png │ │ │ ├── icon-ios-60@2x.png │ │ │ ├── icon-ios-60@3x.png │ │ │ ├── icon-ios-76@2x.png │ │ │ ├── icon-ios-1024@1x.png │ │ │ ├── icon-ios-20@2x-1.png │ │ │ ├── icon-ios-20@2x-2.png │ │ │ ├── icon-ios-29@2x-1.png │ │ │ ├── icon-ios-40@2x-1.png │ │ │ └── icon-ios-83.5@2x.png │ │ ├── logo.imageset │ │ │ └── Contents.json │ │ └── CircleAlert.imageset │ │ │ └── Contents.json │ ├── mul.lproj │ │ └── LaunchScreen.xcstrings │ ├── AXIdentifier │ │ ├── AccessibilityID.swift │ │ ├── Extensions │ │ │ ├── XCUIElementQuery+AccessibilityID.swift │ │ │ └── UIAccessibilityIdentification+AccessibilityID.swift │ │ ├── AccessibilityID+Root+Categories.swift │ │ ├── AccessibilityID+Settings+SelectionMode.swift │ │ ├── AccessibilityID+Shared+Pagination.swift │ │ ├── AccessibilityID+Settings+EditPhrases.swift │ │ ├── AccessibilityID+Settings+ListeningMode.swift │ │ ├── AccessibilityID+Root.swift │ │ ├── AccessibilityID+Settings+VoiceConfiguration.swift.swift │ │ ├── AccessibilityID+Settings+EditCategories.swift │ │ ├── AccessibilityID+Settings+EditCategoryDetails.swift │ │ ├── AccessibilityID+Shared.swift │ │ ├── AccessibilityID+Shared+Alert.swift │ │ ├── AccessibilityID+Settings+TimingSensitivity.swift │ │ ├── AccessibilityID+Settings.swift │ │ └── AccessibilityID+Shared+Keyboard.swift │ └── Info.plist ├── SwiftUI │ ├── Documentation.docc │ │ ├── Documentation.md │ │ └── Resources │ │ │ ├── GazeButton-Roles.png │ │ │ └── GazeButton-Styling.png │ ├── UIKit Bridge │ │ ├── ContentHuggingHostingController.swift │ │ └── ContentHuggingPriority.swift │ ├── Extensions │ │ └── View+.swift │ ├── GazeBlocking.swift │ └── Gaze Button │ │ ├── ButtonState.swift │ │ ├── DefaultGazeButtonStyle.swift │ │ └── GazeButtonStyleConfiguration.swift ├── Features │ ├── Voice │ │ ├── Paused.wav │ │ ├── Listening.wav │ │ ├── VocableChoicesModel.mlmodel │ │ ├── SoundEffect.swift │ │ ├── ListeningFeedbackSuccessView.swift │ │ └── ListenModeDebugStorage.swift │ ├── Settings │ │ ├── ListeningMode │ │ │ └── SettingsFooterTextSupplementaryView.swift │ │ ├── SelectionMode │ │ │ └── Views │ │ │ │ ├── SettingsFooterCollectionViewCell.swift │ │ │ │ └── SettingsCollectionViewCell.swift │ │ ├── VoiceSettings │ │ │ ├── VoiceProfileItem.swift │ │ │ ├── PersonalVoicePermissionPromptController.swift │ │ │ ├── VocableListContentConfiguration+VoiceProfileItem.swift │ │ │ └── VoiceProfilePreviewDataSource+Filter.swift │ │ └── TimingSensitivity │ │ │ └── Model │ │ │ └── CursorSensitivity.swift │ ├── Keyboard │ │ ├── Keypad │ │ │ ├── Layout │ │ │ │ ├── Components │ │ │ │ │ ├── KeyboardLayoutConfiguration.swift │ │ │ │ │ ├── KeyboardLayoutElement.swift │ │ │ │ │ ├── KeyboardLayout.swift │ │ │ │ │ ├── KeyboardLayoutMode.swift │ │ │ │ │ ├── KeyboardKeyAction.swift │ │ │ │ │ ├── KeyboardLayoutElement+Padding.swift │ │ │ │ │ ├── KeyboardBody.swift │ │ │ │ │ ├── KeyboardLayoutRelativeWidth │ │ │ │ │ │ ├── KeyboardLayoutRelativeWidth.swift │ │ │ │ │ │ ├── KeyboardLayoutRelativeWidthFixed.swift │ │ │ │ │ │ ├── KeyboardLayoutRelativeWidthProportional.swift │ │ │ │ │ │ └── KeyboardLayoutRelativeWidthCompound.swift │ │ │ │ │ ├── KeyboardLayoutElement+Sizing.swift │ │ │ │ │ ├── KeyboardLayoutPreviewView.swift │ │ │ │ │ ├── KeyboardLayoutGroup.swift │ │ │ │ │ ├── KeyboardLayoutElement+Environment.swift │ │ │ │ │ ├── KeyboardLayoutRow.swift │ │ │ │ │ ├── KeyboardLayoutKey.swift │ │ │ │ │ ├── KeyboardLayoutDirectionalInsets.swift │ │ │ │ │ ├── KeyboardLayoutContentBuilder.swift │ │ │ │ │ └── KeyboardLayoutBuilder.swift │ │ │ │ └── CompactKeyboardLayoutEN.swift │ │ │ └── UI │ │ │ │ ├── KeyboardViewDelegate.swift │ │ │ │ ├── KeyboardKeyAction+Representation.swift │ │ │ │ └── KeyboardKeyContainerView.swift │ │ ├── KeyboardLocale.swift │ │ └── SuggestionsUI │ │ │ ├── KeyboardSuggestionButton.swift │ │ │ ├── AnimationContainerView.swift │ │ │ └── KeyboardSuggestionAnimationContainer.swift │ └── TextEditor │ │ └── UITraitCollection+isSpeaking.swift ├── HeadTracking │ ├── GazeLib.scnassets │ │ └── axes.scn │ ├── UIHeadGazeRecognizer.swift │ ├── Interpolation │ │ ├── PulseController │ │ │ ├── SingleControlDisplayable.swift │ │ │ ├── FormatterExtensions.swift │ │ │ ├── PulseExtension.swift │ │ │ ├── PIDControlConstants.swift │ │ │ ├── Queue.swift │ │ │ └── TunningViewController.swift │ │ └── InterpolableValues.swift │ ├── UIHeadGazeEvent.swift │ ├── UIHeadGazeTrackingWindow.swift │ ├── UIHeadGazeCursorWindow.swift │ ├── Extensions.swift │ └── ToastWindow.swift ├── APIClient │ └── Models │ │ ├── Query.swift │ │ ├── Exchange.swift │ │ └── Reply.swift ├── Common │ ├── AnalyticsReportable.swift │ ├── Views │ │ ├── WarningView.swift │ │ ├── HighlightableContentCell.swift │ │ ├── PageControlReusableView.swift │ │ ├── GazeableAlertController │ │ │ ├── de.lproj │ │ │ │ └── GazeableAlertViewController.strings │ │ │ ├── en.lproj │ │ │ │ └── GazeableAlertViewController.strings │ │ │ └── GazeableAlertPresentationController.swift │ │ ├── GazeEatingView.swift │ │ ├── ViewControllerWrapperView.swift │ │ ├── TrackingContainerViewController.swift │ │ └── ToastView.swift │ ├── TimeInterval+AnalyticsReportable.swift │ ├── LaunchEnvironment.swift │ ├── NSLayoutConstraint+Priority.swift │ ├── VocableListCell │ │ ├── VocableListCell.swift │ │ └── VocableListCellAccessory.swift │ ├── LaunchArguments.swift │ ├── PublishedValue.swift │ ├── VocableNavigationController.swift │ ├── CategoryReordering │ │ └── CategoryReordering.swift │ ├── CarouselGridLayout+Masks.swift │ ├── NSAttributedString+Helpers.swift │ ├── AppStorage+Init.swift │ └── AddPhraseCollectionViewCell.swift ├── CoreData │ ├── Phrases.xcdatamodeld │ │ ├── .xccurrentversion │ │ ├── Phrases.xcdatamodel │ │ │ └── contents │ │ ├── Phrases v3.xcdatamodel │ │ │ └── contents │ │ └── Phrases v2.xcdatamodel │ │ │ └── contents │ ├── NSPersistentContainer+Shared.swift │ ├── ModelObjectExtensions.swift │ └── NSManagedObject+Helpers.swift ├── Extensions │ ├── CGSize+Helpers.swift │ ├── BidirectionalCollection+.swift │ ├── NSRange+.swift │ ├── Array+Split.swift │ ├── UIApplication+Helpers.swift │ ├── NSDiffableDataSourceSnapshot+Map.swift │ ├── NSPredicate+Operators.swift │ ├── TextSuggestionController.swift │ ├── UIFont+Helpers.swift │ ├── Phrase+Helpers.swift │ ├── UICollectionDiffableDatasource+Mutations.swift │ ├── UICollectionView+Helpers.swift │ └── TextExpression.swift ├── GoogleService-Info.plist └── AppConfig │ └── AppConfig.swift ├── Gemfile ├── CODEOWNERS ├── scripts └── upload-symbols ├── marketing_assets ├── appstore_badge.png └── vocable_vimeo_still.gif ├── crowdin.yml ├── Vocable.xcodeproj └── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── pull_request_template.md ├── fastlane ├── Appfile ├── Matchfile └── README.md ├── Tests ├── VocableUITests │ ├── Common │ │ ├── ResultBuilders.swift │ │ ├── Arguments.swift │ │ ├── XCUIApplication+LaunchConfiguration.swift │ │ └── Environment.swift │ ├── Utilities.swift │ ├── Tests │ │ ├── CustomCategoryBaseTest.swift │ │ ├── PaginationBaseTest.swift │ │ ├── PresetsOverrideTestCase.swift │ │ ├── BaseTest.swift │ │ ├── CategoryIdentifier.swift │ │ ├── KeyboardScreenTests.swift │ │ └── TimingAndSensitivityTests.swift │ ├── Info.plist │ ├── Screens │ │ ├── TimingAndSensitivityScreen.swift │ │ └── KeyboardScreen.swift │ └── XCUIElement.swift ├── VocableTests │ ├── Info.plist │ └── CategoryOrderabilityTests.swift ├── UnitTests.xctestplan └── UITests.xctestplan ├── .github ├── ISSUE_TEMPLATE │ ├── development-task.md │ ├── design-template.md │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── actions │ └── run_lane │ │ └── action.yml │ ├── on_release_push.yml │ ├── crowdin_push.yml │ ├── on_pull_request.yml │ └── crowdin_pull.yml ├── ROADMAP.md ├── .swiftlint.yml ├── LICENSE ├── CONTRIBUTING.md └── .gitignore /Vocable/Supporting Files/de.lproj/LaunchScreen.strings: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /Vocable/SwiftUI/Documentation.docc/Documentation.md: -------------------------------------------------------------------------------- 1 | # ``Vocable`` 2 | 3 | Placeholder 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem 'rb-readline' 4 | gem "fastlane", '2.226.0' 5 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @jrtb @Clstroud @pg8wood @jsmorgan42 @thomasshealy @stevefosterwta @carolinelaw 2 | -------------------------------------------------------------------------------- /scripts/upload-symbols: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willowtreeapps/vocable-ios/HEAD/scripts/upload-symbols -------------------------------------------------------------------------------- /Vocable/Features/Voice/Paused.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willowtreeapps/vocable-ios/HEAD/Vocable/Features/Voice/Paused.wav -------------------------------------------------------------------------------- /marketing_assets/appstore_badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willowtreeapps/vocable-ios/HEAD/marketing_assets/appstore_badge.png -------------------------------------------------------------------------------- /Vocable/Features/Voice/Listening.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willowtreeapps/vocable-ios/HEAD/Vocable/Features/Voice/Listening.wav -------------------------------------------------------------------------------- /Vocable/Supporting Files/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /marketing_assets/vocable_vimeo_still.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willowtreeapps/vocable-ios/HEAD/marketing_assets/vocable_vimeo_still.gif -------------------------------------------------------------------------------- /Vocable/Supporting Files/Assets.xcassets/Colors/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Vocable/Supporting Files/mul.lproj/LaunchScreen.xcstrings: -------------------------------------------------------------------------------- 1 | { 2 | "sourceLanguage" : "en", 3 | "strings" : { 4 | 5 | }, 6 | "version" : "1.0" 7 | } -------------------------------------------------------------------------------- /Vocable/HeadTracking/GazeLib.scnassets/axes.scn: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willowtreeapps/vocable-ios/HEAD/Vocable/HeadTracking/GazeLib.scnassets/axes.scn -------------------------------------------------------------------------------- /Vocable/Features/Voice/VocableChoicesModel.mlmodel: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willowtreeapps/vocable-ios/HEAD/Vocable/Features/Voice/VocableChoicesModel.mlmodel -------------------------------------------------------------------------------- /Vocable/SwiftUI/Documentation.docc/Resources/GazeButton-Roles.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willowtreeapps/vocable-ios/HEAD/Vocable/SwiftUI/Documentation.docc/Resources/GazeButton-Roles.png -------------------------------------------------------------------------------- /Vocable/SwiftUI/Documentation.docc/Resources/GazeButton-Styling.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willowtreeapps/vocable-ios/HEAD/Vocable/SwiftUI/Documentation.docc/Resources/GazeButton-Styling.png -------------------------------------------------------------------------------- /crowdin.yml: -------------------------------------------------------------------------------- 1 | files: 2 | - source: /Vocable/**/*.xcstrings 3 | ignore: 4 | - CrowdinExport 5 | translation: /CrowdinExport/%osx_locale%/%file_name%.xliff 6 | bundles: 7 | - 6 8 | -------------------------------------------------------------------------------- /Vocable/Supporting Files/Assets.xcassets/AppIcon.appiconset/icon-ios-20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willowtreeapps/vocable-ios/HEAD/Vocable/Supporting Files/Assets.xcassets/AppIcon.appiconset/icon-ios-20@2x.png -------------------------------------------------------------------------------- /Vocable/Supporting Files/Assets.xcassets/AppIcon.appiconset/icon-ios-20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willowtreeapps/vocable-ios/HEAD/Vocable/Supporting Files/Assets.xcassets/AppIcon.appiconset/icon-ios-20@3x.png -------------------------------------------------------------------------------- /Vocable/Supporting Files/Assets.xcassets/AppIcon.appiconset/icon-ios-29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willowtreeapps/vocable-ios/HEAD/Vocable/Supporting Files/Assets.xcassets/AppIcon.appiconset/icon-ios-29@2x.png -------------------------------------------------------------------------------- /Vocable/Supporting Files/Assets.xcassets/AppIcon.appiconset/icon-ios-29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willowtreeapps/vocable-ios/HEAD/Vocable/Supporting Files/Assets.xcassets/AppIcon.appiconset/icon-ios-29@3x.png -------------------------------------------------------------------------------- /Vocable/Supporting Files/Assets.xcassets/AppIcon.appiconset/icon-ios-40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willowtreeapps/vocable-ios/HEAD/Vocable/Supporting Files/Assets.xcassets/AppIcon.appiconset/icon-ios-40@2x.png -------------------------------------------------------------------------------- /Vocable/Supporting Files/Assets.xcassets/AppIcon.appiconset/icon-ios-40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willowtreeapps/vocable-ios/HEAD/Vocable/Supporting Files/Assets.xcassets/AppIcon.appiconset/icon-ios-40@3x.png -------------------------------------------------------------------------------- /Vocable/Supporting Files/Assets.xcassets/AppIcon.appiconset/icon-ios-60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willowtreeapps/vocable-ios/HEAD/Vocable/Supporting Files/Assets.xcassets/AppIcon.appiconset/icon-ios-60@2x.png -------------------------------------------------------------------------------- /Vocable/Supporting Files/Assets.xcassets/AppIcon.appiconset/icon-ios-60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willowtreeapps/vocable-ios/HEAD/Vocable/Supporting Files/Assets.xcassets/AppIcon.appiconset/icon-ios-60@3x.png -------------------------------------------------------------------------------- /Vocable/Supporting Files/Assets.xcassets/AppIcon.appiconset/icon-ios-76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willowtreeapps/vocable-ios/HEAD/Vocable/Supporting Files/Assets.xcassets/AppIcon.appiconset/icon-ios-76@2x.png -------------------------------------------------------------------------------- /Vocable/Supporting Files/Assets.xcassets/AppIcon.appiconset/icon-ios-1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willowtreeapps/vocable-ios/HEAD/Vocable/Supporting Files/Assets.xcassets/AppIcon.appiconset/icon-ios-1024@1x.png -------------------------------------------------------------------------------- /Vocable/Supporting Files/Assets.xcassets/AppIcon.appiconset/icon-ios-20@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willowtreeapps/vocable-ios/HEAD/Vocable/Supporting Files/Assets.xcassets/AppIcon.appiconset/icon-ios-20@2x-1.png -------------------------------------------------------------------------------- /Vocable/Supporting Files/Assets.xcassets/AppIcon.appiconset/icon-ios-20@2x-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willowtreeapps/vocable-ios/HEAD/Vocable/Supporting Files/Assets.xcassets/AppIcon.appiconset/icon-ios-20@2x-2.png -------------------------------------------------------------------------------- /Vocable/Supporting Files/Assets.xcassets/AppIcon.appiconset/icon-ios-29@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willowtreeapps/vocable-ios/HEAD/Vocable/Supporting Files/Assets.xcassets/AppIcon.appiconset/icon-ios-29@2x-1.png -------------------------------------------------------------------------------- /Vocable/Supporting Files/Assets.xcassets/AppIcon.appiconset/icon-ios-40@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willowtreeapps/vocable-ios/HEAD/Vocable/Supporting Files/Assets.xcassets/AppIcon.appiconset/icon-ios-40@2x-1.png -------------------------------------------------------------------------------- /Vocable/Supporting Files/Assets.xcassets/AppIcon.appiconset/icon-ios-83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willowtreeapps/vocable-ios/HEAD/Vocable/Supporting Files/Assets.xcassets/AppIcon.appiconset/icon-ios-83.5@2x.png -------------------------------------------------------------------------------- /Vocable.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /pull_request_template.md: -------------------------------------------------------------------------------- 1 | https://github.com/willowtreeapps/vocable-ios/issues/### 2 | 3 | closes ### 4 | 5 | *Add the ticket number to above* 6 | 7 | # Description of Work 8 | Description 9 | 10 | ## Notes to Test (Optional) 11 | Notes 12 | 13 | --- 14 | -------------------------------------------------------------------------------- /fastlane/Appfile: -------------------------------------------------------------------------------- 1 | # app_identifier("[[APP_IDENTIFIER]]") # The bundle identifier of your app 2 | # apple_id("[[APPLE_ID]]") # Your Apple email address 3 | 4 | 5 | # For more information about the Appfile, see: 6 | # https://docs.fastlane.tools/advanced/#appfile 7 | -------------------------------------------------------------------------------- /fastlane/Matchfile: -------------------------------------------------------------------------------- 1 | git_url("git@github.com:willowtreeapps/eyespeak-match.git") 2 | 3 | storage_mode("git") 4 | 5 | type("development") # The default type, can be: appstore, adhoc, enterprise or development 6 | 7 | app_identifier(["com.willowtreeapps.eyespeakaac"]) 8 | team_id("753J68XC6W") # WillowRoot Apps, LLC -------------------------------------------------------------------------------- /Vocable/APIClient/Models/Query.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Query.swift 3 | // Vocable 4 | // 5 | // Created by Andrew Carter on 8/17/23. 6 | // Copyright © 2023 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Query: Codable { 12 | let prompt: String 13 | let history: [Exchange] 14 | } 15 | -------------------------------------------------------------------------------- /Vocable/APIClient/Models/Exchange.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Exchange.swift 3 | // Vocable 4 | // 5 | // Created by Andrew Carter on 8/17/23. 6 | // Copyright © 2023 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Exchange: Codable { 12 | let prompt: String 13 | let response: String 14 | } 15 | -------------------------------------------------------------------------------- /Vocable.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Vocable/APIClient/Models/Reply.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QueryResult.swift 3 | // Vocable 4 | // 5 | // Created by Andrew Carter on 8/17/23. 6 | // Copyright © 2023 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Reply: Decodable { 12 | let responses: [String] 13 | let history: [Exchange] 14 | } 15 | -------------------------------------------------------------------------------- /Vocable/Common/AnalyticsReportable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnalyticsReportable.swift 3 | // Vocable 4 | // 5 | // Created by Jesse Morgan on 5/11/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol AnalyticsReportable { 12 | var analyticsDescription: String { get } 13 | } 14 | -------------------------------------------------------------------------------- /Vocable/CoreData/Phrases.xcdatamodeld/.xccurrentversion: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | _XCCurrentVersionName 6 | Phrases v4.xcdatamodel 7 | 8 | 9 | -------------------------------------------------------------------------------- /Vocable/Extensions/CGSize+Helpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CGSize+Helpers.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 4/16/20. 6 | // Copyright © 2020 WillowTree. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension CGSize { 12 | var area: CGFloat { 13 | return width * height 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Vocable/Common/Views/WarningView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WarningView.swift 3 | // Vocable 4 | // 5 | // Created by Martin Pittenauer on 20.03.20. 6 | // Copyright © 2020 WillowTree. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | @IBDesignable 12 | class WarningView: UIView { 13 | 14 | @IBOutlet weak var label: UILabel? 15 | 16 | } 17 | -------------------------------------------------------------------------------- /Vocable/Supporting Files/Assets.xcassets/logo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "logo.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | }, 12 | "properties" : { 13 | "preserves-vector-representation" : true 14 | } 15 | } -------------------------------------------------------------------------------- /Tests/VocableUITests/Common/ResultBuilders.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ResultBuilders.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 4/25/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | @resultBuilder 12 | enum ListBuilder { 13 | static func buildBlock(_ components: T...) -> [T] { 14 | components 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Vocable/Common/TimeInterval+AnalyticsReportable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimeInterval+AnalyticsReportable.swift 3 | // Vocable 4 | // 5 | // Created by Jesse Morgan on 5/11/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension TimeInterval: AnalyticsReportable { 12 | var analyticsDescription: String { 13 | "\(self)s" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/development-task.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Development Task 3 | about: Used for creating new issues for development 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Acceptance Criteria**: 11 | 12 | any scenarios or conditions that need to be satisfied 13 | If familiar with Gherkin format, follow that template. 14 | 15 | **Design**: 16 | 17 | Screenshots or Link to Figma 18 | -------------------------------------------------------------------------------- /Vocable/Common/Views/HighlightableContentCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HighlightableContentCell.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 5/22/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | protocol HighlightableContentCell: UICollectionViewCell { 13 | @MainActor 14 | func setHighlightRange(_ range: NSRange?) 15 | } 16 | -------------------------------------------------------------------------------- /Vocable/Common/Views/PageControlReusableView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PresetPageControl.swift 3 | // Vocable AAC 4 | // 5 | // Created by Jesse Morgan on 1/30/20. 6 | // Copyright © 2020 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | class PresetPageControlReusableView: UICollectionReusableView { 13 | 14 | @IBOutlet var pageControl: UIPageControl! 15 | 16 | } 17 | -------------------------------------------------------------------------------- /Vocable/Supporting Files/AXIdentifier/AccessibilityID.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccessibilityIdentifiers.swift 3 | // Vocable 4 | // 5 | // Created by Rhonda Oglesby on 4/29/22. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct AccessibilityID: ExpressibleByStringLiteral, Hashable { 11 | let id: String 12 | public init(stringLiteral value: StringLiteralType) { 13 | self.id = value 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Vocable/Features/Settings/ListeningMode/SettingsFooterTextSupplementaryView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsFooterTextSupplementaryView.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 2/19/21. 6 | // Copyright © 2021 WillowTree. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class SettingsFooterTextSupplementaryView: UICollectionReusableView { 12 | @IBOutlet private(set) var textLabel: UILabel! 13 | } 14 | -------------------------------------------------------------------------------- /Vocable/Extensions/BidirectionalCollection+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BidirectionalCollection+.swift 3 | // Vocable 4 | // 5 | // Created by Robert Moyer on 3/23/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | extension BidirectionalCollection { 10 | /// The range spanning all valid indices of the collection 11 | var rangeOfIndices: Range { 12 | startIndex ..< endIndex 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Vocable/Features/Keyboard/Keypad/Layout/Components/KeyboardLayoutConfiguration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardLayoutConfiguration.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 7/31/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct KeyboardLayoutConfiguration { 12 | let mode: KeyboardLayoutMode 13 | let modifierGrapheme: Character? 14 | let sizeClass: SizeClass 15 | } 16 | -------------------------------------------------------------------------------- /Vocable/Features/Keyboard/Keypad/UI/KeyboardViewDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardViewDelegate.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 7/31/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol KeyboardViewDelegate: AnyObject { 12 | func keyboardViewDidSelectSuggestion(_ suggestion: String) 13 | func keyboardViewDidSelectKey(_ value: KeyboardLayoutKey) 14 | } 15 | -------------------------------------------------------------------------------- /Vocable/Common/Views/GazeableAlertController/de.lproj/GazeableAlertViewController.strings: -------------------------------------------------------------------------------- 1 | 2 | /* Class = "UILabel"; text = "Lorem ipsum dolor"; ObjectID = "Qxz-Cn-o5q"; */ 3 | "Qxz-Cn-o5q.text" = "Lorem ipsum dolor"; 4 | 5 | /* Class = "UIButton"; normalTitle = "Confirm"; ObjectID = "he4-Sq-X8i"; */ 6 | "he4-Sq-X8i.normalTitle" = "Okay"; 7 | 8 | /* Class = "UIButton"; normalTitle = "Cancel"; ObjectID = "rA5-3d-GQF"; */ 9 | "rA5-3d-GQF.normalTitle" = "Abbrechen"; 10 | -------------------------------------------------------------------------------- /Vocable/Common/Views/GazeableAlertController/en.lproj/GazeableAlertViewController.strings: -------------------------------------------------------------------------------- 1 | 2 | /* Class = "UILabel"; text = "Lorem ipsum dolor"; ObjectID = "Qxz-Cn-o5q"; */ 3 | "Qxz-Cn-o5q.text" = "Lorem ipsum dolor"; 4 | 5 | /* Class = "UIButton"; normalTitle = "Confirm"; ObjectID = "he4-Sq-X8i"; */ 6 | "he4-Sq-X8i.normalTitle" = "Okay"; 7 | 8 | /* Class = "UIButton"; normalTitle = "Cancel"; ObjectID = "rA5-3d-GQF"; */ 9 | "rA5-3d-GQF.normalTitle" = "Cancel"; 10 | -------------------------------------------------------------------------------- /Vocable/Supporting Files/Assets.xcassets/CircleAlert.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "CircleAlert.pdf", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Vocable/Features/Keyboard/Keypad/Layout/Components/KeyboardLayoutElement.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardLayoutElement.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 7/31/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol KeyboardLayoutElement { 12 | var children: [any KeyboardLayoutElement] { get set } 13 | func makeKeys(environment: KeyboardLayoutEnvironment) -> [KeyboardLayoutKey] 14 | } 15 | -------------------------------------------------------------------------------- /Vocable/Features/Keyboard/Keypad/Layout/Components/KeyboardLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardLayout.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 5/24/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | protocol KeyboardLayout { 13 | 14 | var identifier: String { get } 15 | 16 | @KeyboardBuilder 17 | func makeLayout(configuration: KeyboardLayoutConfiguration) -> KeyboardBody 18 | } 19 | -------------------------------------------------------------------------------- /Vocable/Supporting Files/Assets.xcassets/Colors/ErrorRed.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "108", 9 | "green" : "0", 10 | "red" : "173" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/design-template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Design Template 3 | about: Used for new Design Tasks in the Vocable Project 4 | title: '' 5 | labels: Design Updates 6 | assignees: '' 7 | 8 | --- 9 | 10 | #### Design - Area of Focus 11 | This is a template. Description of what you are working on. 12 | 13 | - [x] Item 1 to work on 14 | - [ ] Item 2 to work on 15 | - [ ] Item 3 to work on 16 | 17 | *or* 18 | * Item 1 to work on 19 | * Item 2 to work on 20 | * Item 3 to work on 21 | -------------------------------------------------------------------------------- /Vocable/Features/Keyboard/Keypad/Layout/Components/KeyboardLayoutMode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardLayoutMode.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 7/31/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum KeyboardLayoutMode: Equatable { 12 | case alphabetical 13 | case modifierPicker 14 | case numerical 15 | 16 | var isAlphabetical: Bool { 17 | self == .alphabetical 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Vocable/Supporting Files/Assets.xcassets/Colors/Background.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | }, 6 | "colors" : [ 7 | { 8 | "idiom" : "universal", 9 | "color" : { 10 | "color-space" : "srgb", 11 | "components" : { 12 | "red" : "0.128", 13 | "alpha" : "1.000", 14 | "blue" : "0.358", 15 | "green" : "0.109" 16 | } 17 | } 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /Vocable/Supporting Files/Assets.xcassets/Colors/GrayDivider.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | }, 6 | "colors" : [ 7 | { 8 | "idiom" : "universal", 9 | "color" : { 10 | "color-space" : "srgb", 11 | "components" : { 12 | "red" : "0.635", 13 | "alpha" : "1.000", 14 | "blue" : "0.690", 15 | "green" : "0.631" 16 | } 17 | } 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /Vocable/Supporting Files/Assets.xcassets/Colors/Selection.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | }, 6 | "colors" : [ 7 | { 8 | "idiom" : "universal", 9 | "color" : { 10 | "color-space" : "srgb", 11 | "components" : { 12 | "red" : "0.214", 13 | "alpha" : "1.000", 14 | "blue" : "0.602", 15 | "green" : "0.980" 16 | } 17 | } 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /Vocable/Supporting Files/Assets.xcassets/Colors/TextHighlight.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | }, 6 | "colors" : [ 7 | { 8 | "idiom" : "universal", 9 | "color" : { 10 | "color-space" : "srgb", 11 | "components" : { 12 | "red" : "0.298", 13 | "alpha" : "1.000", 14 | "blue" : "0.976", 15 | "green" : "0.852" 16 | } 17 | } 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /Vocable/Supporting Files/Assets.xcassets/Colors/BorderHighlight.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | }, 6 | "colors" : [ 7 | { 8 | "idiom" : "universal", 9 | "color" : { 10 | "color-space" : "srgb", 11 | "components" : { 12 | "red" : "0.975", 13 | "alpha" : "1.000", 14 | "blue" : "0.185", 15 | "green" : "0.645" 16 | } 17 | } 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /Vocable/Supporting Files/Assets.xcassets/Colors/CategoryBackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | }, 6 | "colors" : [ 7 | { 8 | "idiom" : "universal", 9 | "color" : { 10 | "color-space" : "srgb", 11 | "components" : { 12 | "red" : "0.312", 13 | "alpha" : "1.000", 14 | "blue" : "0.573", 15 | "green" : "0.294" 16 | } 17 | } 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /Vocable/Supporting Files/Assets.xcassets/Colors/DefaultFontColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | }, 6 | "colors" : [ 7 | { 8 | "idiom" : "universal", 9 | "color" : { 10 | "color-space" : "srgb", 11 | "components" : { 12 | "red" : "0.816", 13 | "alpha" : "1.000", 14 | "blue" : "0.913", 15 | "green" : "0.932" 16 | } 17 | } 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /Vocable/Supporting Files/Assets.xcassets/Colors/AlertBackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "0.800", 8 | "blue" : "0.949", 9 | "green" : "0.949", 10 | "red" : "0.949" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Vocable/Supporting Files/Assets.xcassets/Colors/DefaultCellBackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | }, 6 | "colors" : [ 7 | { 8 | "idiom" : "universal", 9 | "color" : { 10 | "color-space" : "srgb", 11 | "components" : { 12 | "red" : "0.218", 13 | "alpha" : "1.000", 14 | "blue" : "0.627", 15 | "green" : "0.195" 16 | } 17 | } 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /Vocable/Supporting Files/Assets.xcassets/Colors/selectedCellBackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | }, 6 | "colors" : [ 7 | { 8 | "idiom" : "universal", 9 | "color" : { 10 | "color-space" : "srgb", 11 | "components" : { 12 | "red" : "0x73", 13 | "alpha" : "1.000", 14 | "blue" : "0xFF", 15 | "green" : "0x00" 16 | } 17 | } 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /Vocable/Extensions/NSRange+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StringProtocol+Range.swift 3 | // Vocable 4 | // 5 | // Created by Robert Moyer on 3/22/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension NSRange { 12 | init(of s: S) { 13 | self.init(s.rangeOfIndices, in: s) 14 | } 15 | 16 | static func entireRange(of s: S) -> NSRange { 17 | NSRange(s.rangeOfIndices, in: s) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Vocable/Common/LaunchEnvironment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LaunchEnvironment.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 4/22/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum LaunchEnvironment { 12 | 13 | public enum Key: String { 14 | case overridePresets 15 | case mixpanelToken 16 | } 17 | 18 | static func value(for key: Key) -> String? { 19 | ProcessInfo.processInfo.environment[key.rawValue] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Vocable/Common/Views/GazeEatingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GazeEatingView.swift 3 | // Vocable 4 | // 5 | // Created by Steve Foster on 3/24/20. 6 | // Copyright © 2020 WillowTree. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | final class GazeEatingView: UIView { 12 | 13 | override func gazeableHitTest(_ point: CGPoint, with event: UIHeadGazeEvent?) -> UIView? { 14 | // Hit test this view's subviews, otherwise swallow the gazeable hit test 15 | super.gazeableHitTest(point, with: event) ?? self 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /Vocable/SwiftUI/UIKit Bridge/ContentHuggingHostingController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentHuggingHostingController.swift 3 | // Vocable 4 | // 5 | // Created by Robert Moyer on 4/6/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import UIKit 11 | 12 | class ContentHuggingHostingController: UIHostingController { 13 | override func viewDidLayoutSubviews() { 14 | super.viewDidLayoutSubviews() 15 | preferredContentSize = view.intrinsicContentSize 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Vocable/SwiftUI/Extensions/View+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+.swift 3 | // Vocable 4 | // 5 | // Created by Robert Moyer on 6/6/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | extension View { 12 | @available(iOS, obsoleted: 15, message: "Please use the built-in overlay modifier") 13 | func overlay( 14 | alignment: Alignment = .center, 15 | @ViewBuilder _ content: () -> Content 16 | ) -> some View where Content: View { 17 | overlay(content()) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Vocable/Common/Views/ViewControllerWrapperView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewControllerWrapperView.swift 3 | // Vocable AAC 4 | // 5 | // Created by Chris Stroud on 2/4/20. 6 | // Copyright © 2020 WillowTree. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | final class ViewControllerWrapperView: UIView { 12 | override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { 13 | guard let result = super.hitTest(point, with: event), result != self else { 14 | return nil 15 | } 16 | return result 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Vocable/Features/Settings/SelectionMode/Views/SettingsFooterCollectionViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsFooterCollectionViewCell.swift 3 | // Vocable AAC 4 | // 5 | // Created by Jesse Morgan on 2/10/20. 6 | // Copyright © 2020 WillowTree. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | final class SettingsFooterCollectionViewCell: UICollectionViewCell { 12 | 13 | @IBOutlet private weak var versionLabel: UILabel! 14 | 15 | func setup(versionLabel: String) { 16 | self.versionLabel.text = versionLabel 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /Vocable/Supporting Files/AXIdentifier/Extensions/XCUIElementQuery+AccessibilityID.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XCUIElementQuery+AccessibilityID.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 5/11/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import XCTest 11 | 12 | public extension XCUIElementQuery { 13 | subscript(_ id: AccessibilityID) -> XCUIElement { 14 | let predicate = NSPredicate(format: "identifier MATCHES %@", id.id) 15 | return self.element(matching: predicate) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/actions/run_lane/action.yml: -------------------------------------------------------------------------------- 1 | name: Run Lane 2 | description: Run a fastane lane and upload artifacts 3 | inputs: 4 | lane: 5 | description: Name of fastlane lane to run 6 | required: true 7 | match_password: 8 | description: Match password, ideally in secrets.MATCH_PASSWORD 9 | required: false 10 | runs: 11 | using: composite 12 | steps: 13 | - name: Run fastlane 14 | run: bundle exec fastlane ${{ inputs.lane }} 15 | shell: sh 16 | env: 17 | MATCH_PASSWORD: ${{ inputs.match_password }} 18 | 19 | # TODO: Artifacts 20 | -------------------------------------------------------------------------------- /Vocable/Supporting Files/AXIdentifier/AccessibilityID+Root+Categories.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccessibilityID+Root+Categories.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 5/11/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension AccessibilityID.root { 12 | public struct categories { 13 | public static let previousButton: AccessibilityID = "root-categories-previous-button" 14 | public static let nextButton: AccessibilityID = "root-categories-next-button" 15 | private init() {} 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Tests/VocableUITests/Common/Arguments.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LaunchArguments+StringArray.swift 3 | // VocableUITests 4 | // 5 | // Created by Jesse Morgan on 4/22/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import XCTest 11 | 12 | struct Arguments: XCTestAppConfigurable { 13 | 14 | private let keys: [LaunchArguments.Key] 15 | 16 | init(_ keys: LaunchArguments.Key...) { 17 | self.keys = keys 18 | } 19 | 20 | func configure(_ app: XCUIApplication) { 21 | app.launchArguments = keys.map(\.rawValue) 22 | } 23 | } 24 | 25 | 26 | -------------------------------------------------------------------------------- /Vocable/Common/NSLayoutConstraint+Priority.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSLayoutConstraint+Priority.swift 3 | // Vocable 4 | // 5 | // Created by Jesse Morgan on 3/17/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension NSLayoutConstraint { 12 | 13 | func withPriority(_ priority: UILayoutPriority) -> NSLayoutConstraint { 14 | self.priority = priority 15 | return self 16 | } 17 | 18 | func withPriority(_ rawPriority: Float) -> NSLayoutConstraint { 19 | self.priority = UILayoutPriority(rawValue: rawPriority) 20 | return self 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Vocable/HeadTracking/UIHeadGazeRecognizer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit.UIGestureRecognizerSubclass 3 | 4 | class UIHeadGazeRecognizer: UIGestureRecognizer { 5 | 6 | func gazeBegan(_ gaze: UIHeadGaze, with event: UIHeadGazeEvent?) { 7 | // No-op 8 | } 9 | 10 | func gazeMoved(_ gaze: UIHeadGaze, with event: UIHeadGazeEvent?) { 11 | // No-op 12 | } 13 | 14 | func gazeEnded(_ gaze: UIHeadGaze, with event: UIHeadGazeEvent?) { 15 | // No-op 16 | } 17 | 18 | func gazeCancelled(_ gaze: UIHeadGaze, with event: UIHeadGazeEvent?) { 19 | // No-op 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /Vocable/Features/Keyboard/KeyboardLocale.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardModel.swift 3 | // Vocable AAC 4 | // 5 | // Created by Jesse Morgan on 3/3/20. 6 | // Copyright © 2020 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | enum KeyboardLocale: String { 13 | case en 14 | case de 15 | case it 16 | 17 | static var current: Self { 18 | let preferredLanguageCode = AppConfig.activePreferredLanguageCode 19 | let code = Locale(identifier: preferredLanguageCode).languageCode ?? AppConfig.defaultLanguageCode 20 | return Self(rawValue: code) ?? .en 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Vocable/Supporting Files/AXIdentifier/AccessibilityID+Settings+SelectionMode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccessibilityID+Settings+SelectionMode.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 5/11/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension AccessibilityID.settings { 12 | public struct selectionMode { 13 | public static let headTrackingToggle: AccessibilityID = "selection-mode-head-tracking-toggle" 14 | public static let compactQwertyToggle: AccessibilityID = "selection-mode-compact-qwerty-toggle" 15 | private init() {} 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Vocable/Common/VocableListCell/VocableListCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VocableListCell.swift 3 | // Vocable 4 | // 5 | // Created by Jesse Morgan on 3/14/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | import UIKit 9 | 10 | final class VocableListCell: UICollectionViewListCell { 11 | 12 | override func updateConfiguration(using state: UICellConfigurationState) { 13 | super.updateConfiguration(using: state) 14 | 15 | var background = UIBackgroundConfiguration.listSidebarCell() 16 | background.backgroundColor = .primaryBackgroundColor 17 | backgroundConfiguration = background 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /ROADMAP.md: -------------------------------------------------------------------------------- 1 | # Vocable Roadmap 2 | 3 | - [ ] Handset Support 4 | - [ ] Edit and delete customized presets 5 | - [ ] Custom Categories: create, edit, and delete 6 | - [ ] Custom category/phrase sorting and UI placement 7 | - [ ] History: recently-spoken phrases 8 | - [ ] Head tracking calibration 9 | - [ ] Audio feedback for navigation 10 | - [ ] Potential Voice integration 11 | - [ ] Alternative modalities / input actions (e.g. touch, joystick method, scanning) 12 | - [ ] Light / Dark Mode 13 | - [ ] Localization of app strings 14 | - [ ] Localization of keyboard(s) 15 | - [ ] Photo upload / pictoral buttons 16 | - [ ] Translate selected phrase ↔️ spoken phrase -------------------------------------------------------------------------------- /Tests/VocableUITests/Common/XCUIApplication+LaunchConfiguration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XCUIApplication+LaunchConfiguration.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 4/25/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import XCTest 11 | 12 | protocol XCTestAppConfigurable { 13 | func configure(_ app: XCUIApplication) 14 | } 15 | 16 | extension XCUIApplication { 17 | func configure(@ListBuilder _ builder: () -> [XCTestAppConfigurable]) { 18 | builder().forEach { configurable in 19 | configurable.configure(self) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Vocable/HeadTracking/Interpolation/PulseController/SingleControlDisplayable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SingleControlDisplayable.swift 3 | // Pulse 4 | // 5 | // Created by Dawid Cieslak on 15/04/2018. 6 | // Copyright © 2018 Dawid Cieslak. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | /// Data required to present Pulse's single configuration 12 | protocol SingleControlDisplayable { 13 | 14 | /// Name of factor being controlled 15 | var name: String { get } 16 | 17 | /// Minimum value that can be set 18 | var minimumValue: Float { get } 19 | 20 | /// Maximum value that can be set 21 | var maximumValue: Float { get } 22 | } 23 | -------------------------------------------------------------------------------- /Vocable/Features/Keyboard/SuggestionsUI/KeyboardSuggestionButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardSuggestionButton.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 7/31/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension KeyboardSuggestionsView { 12 | final class SuggestionButton: GazeableButton { 13 | override func updateConfiguration() { 14 | fillColor = .categoryBackgroundColor 15 | font = .keyboardKey(satisfying: traitCollection) 16 | super.updateConfiguration() 17 | configuration?.titleLineBreakMode = .byTruncatingTail 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Vocable/Supporting Files/AXIdentifier/AccessibilityID+Shared+Pagination.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccessibilityID+Shared+Pagination.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 5/11/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension AccessibilityID.shared { 12 | public struct pagination { 13 | public static let previousButton: AccessibilityID = "shared-pagination-previous-button" 14 | public static let nextButton: AccessibilityID = "shared-pagination-next-button" 15 | public static let pageLabel: AccessibilityID = "shared-pagination-page-label" 16 | private init() {} 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Vocable/Supporting Files/AXIdentifier/AccessibilityID+Settings+EditPhrases.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccessibilityID+Settings+EditPhrases.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 5/11/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension AccessibilityID.settings { 12 | public struct editPhrases { 13 | public static let addPhraseButton: AccessibilityID = "edit-phrases-add-phrase-button" 14 | public static let editPhraseButton: AccessibilityID = "edit-phrase-button" 15 | public static let deletePhraseButton: AccessibilityID = "delete-phrase-button" 16 | private init() {} 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Vocable/Supporting Files/AXIdentifier/AccessibilityID+Settings+ListeningMode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccessibilityID+Settings+ListeningMode.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 8/21/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension AccessibilityID.settings { 12 | public struct listeningMode { 13 | public static let listeningModeToggle: AccessibilityID = "listening_mode_toggle" 14 | public static let hotWordEnabledToggle: AccessibilityID = "hot_word_toggle" 15 | public static let smartAssistEnabledToggle: AccessibilityID = "use_gpt_toggle" 16 | private init() {} 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Tests/VocableUITests/Utilities.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Utilities.swift 3 | // VocableUITests 4 | // 5 | // Created by Canan Arikan on 6/23/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import XCTest 11 | 12 | class Utilities { 13 | 14 | static func restartApp() { 15 | XCUIApplication().terminate() 16 | XCUIApplication().activate() 17 | } 18 | 19 | static func restartApp(withLaunchArguments launchArguments: Arguments) { 20 | let app = XCUIApplication() 21 | app.configure { 22 | launchArguments 23 | } 24 | app.terminate() 25 | app.activate() 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /Tests/VocableUITests/Tests/CustomCategoryBaseTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomCategoryBaseTest.swift 3 | // VocableUITests 4 | // 5 | // Created by Canan Arikan and Rudy Salas on 4/5/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | class CustomCategoryBaseTest: BaseTest { 12 | 13 | private(set) var customCategoryName: String = "Test" 14 | private(set) var nameSuffix: String = "add" 15 | 16 | override func setUpWithError() throws { 17 | try super.setUpWithError() 18 | try SettingsScreen.navigateToSettingsCategoryScreen() 19 | try CustomCategoriesScreen.createCustomCategory(categoryName: customCategoryName) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Vocable/HeadTracking/UIHeadGazeEvent.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | class UIHeadGazeEvent: UIEvent { 5 | public var allGazes: Set? 6 | override var allTouches: Set? { 7 | return allGazes 8 | } 9 | 10 | /** 11 | The time when the event occurred 12 | */ 13 | private var _timestamp: TimeInterval 14 | 15 | /** 16 | Returns the time when the event occurred 17 | */ 18 | public var timeStamp: TimeInterval { 19 | return _timestamp 20 | } 21 | 22 | init(allGazes: Set? = nil) { 23 | self.allGazes = allGazes 24 | self._timestamp = Date().timeIntervalSince1970 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Vocable/Supporting Files/AXIdentifier/Extensions/UIAccessibilityIdentification+AccessibilityID.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIAccessibilityIdentification+AccessibilityID.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 5/11/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | extension UIAccessibilityIdentification { 13 | var accessibilityID: AccessibilityID? { 14 | get { 15 | accessibilityIdentifier.map { value in 16 | AccessibilityID(stringLiteral: value) 17 | } 18 | } 19 | set { 20 | self.accessibilityIdentifier = newValue?.id 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /Vocable/Extensions/Array+Split.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Array+Split.swift 3 | // Vocable AAC 4 | // 5 | // Created by Duncan Lewis on 11/6/18. 6 | // Copyright © 2018 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension Array { 12 | func chunked(into size: Int) -> [[Element]] { 13 | return stride(from: 0, to: count, by: size).map { 14 | Array(self[$0.. Element? { 23 | return indices.contains(index) ? self[index] : nil 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Vocable/Extensions/UIApplication+Helpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URL+Helpers.swift 3 | // Vocable 4 | // 5 | // Created by Jesse Morgan on 6/2/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UIApplication { 12 | 13 | static func openSettingsURL() { 14 | if let url = URL(string: self.openSettingsURLString) { 15 | if shared.canOpenURL(url) { 16 | shared.open(url, options: [:], completionHandler: nil) 17 | } 18 | } 19 | } 20 | 21 | var connectedSceneWindows: [UIWindow] { 22 | connectedScenes 23 | .compactMap { $0 as? UIWindowScene } 24 | .flatMap(\.windows) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Vocable/Common/LaunchArguments.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LaunchArguments.swift 3 | // Vocable 4 | // 5 | // Created by Jesse Morgan on 4/22/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct LaunchArguments { 12 | 13 | public enum Key: String { 14 | case resetAppDataOnLaunch 15 | case enableListeningMode 16 | case disableAnimations 17 | } 18 | 19 | static func contains(_ key: Key) -> Bool { 20 | CommandLine 21 | .arguments 22 | .compactMap(LaunchArguments.Key.init) 23 | .contains(key) 24 | } 25 | 26 | let keys: [Key] 27 | 28 | private init() { 29 | keys = [] 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Vocable/Supporting Files/AXIdentifier/AccessibilityID+Root.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccessibilityID+Root.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 5/11/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension AccessibilityID { 12 | public struct root { 13 | public static let outputText: AccessibilityID = "root-output-text" 14 | public static let categoryBackButton: AccessibilityID = "root-category-back-button" 15 | public static let categoryForwardButton: AccessibilityID = "root-category-forward-button" 16 | public static let addPhraseButton: AccessibilityID = "root-add-phrase-button" 17 | private init() {} 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Vocable/Supporting Files/AXIdentifier/AccessibilityID+Settings+VoiceConfiguration.swift.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccessibilityID+Settings+VoiceConfiguration.swift.swift 3 | // Vocable 4 | // 5 | // Created by Steve Foster on 4/23/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension AccessibilityID.settings { 12 | public struct voiceSettings { 13 | public static let playButton: AccessibilityID = "voice-configuration-play-voice-button" 14 | public static let audioPlaying: AccessibilityID = "voice-configuration-audio-playing-button" 15 | public static let previewVoiceCell: AccessibilityID = "voice-settings-preview-voice-cell" 16 | private init() {} 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Actual behavior** 21 | A clear and concise description of what is currently happening. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Device Information** 27 | 28 | - Device: [e.g. iPhone 11] 29 | - OS: [e.g. iOS 13.4] 30 | -------------------------------------------------------------------------------- /.github/workflows/on_release_push.yml: -------------------------------------------------------------------------------- 1 | name: On release push 2 | on: 3 | push: 4 | branches: 5 | - release.** 6 | jobs: 7 | on_release_push: 8 | runs-on: macos-14 9 | steps: 10 | - uses: actions/checkout@v4 11 | 12 | - name: Run Unit and UI tests 13 | uses: ./.github/workflows/actions/run_lane 14 | with: 15 | lane: test_unit_ui 16 | match_password: ${{ secrets.MATCH_PASSWORD }} 17 | 18 | - name: Commit translation file if changed 19 | uses: ./.github/workflows/actions/xliff_export 20 | 21 | - name: Build and deploy to TestFlight 22 | uses: ./.github/workflows/actions/run_lane 23 | with: 24 | lane: build_deploy_testflight 25 | match_password: ${{ secrets.MATCH_PASSWORD }} 26 | -------------------------------------------------------------------------------- /Vocable/Features/Keyboard/Keypad/Layout/Components/KeyboardKeyAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardKeyAction.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 8/2/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public enum KeyboardKeyAction: Hashable { 12 | case clear 13 | case backspace 14 | case space 15 | case speak 16 | case numberPad 17 | case alphabet 18 | case openModifierPicker 19 | case closeModifierPicker 20 | case beginModifier(Character) 21 | case endModifier(Character) 22 | case insertCharacter(Character) 23 | 24 | var isStandardKey: Bool { 25 | if case .insertCharacter = self { 26 | return true 27 | } 28 | return false 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Vocable/HeadTracking/UIHeadGazeTrackingWindow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIHeadGazeTrackingWindow.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 4/24/20. 6 | // Copyright © 2020 WillowTree. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | final class UIHeadGazeTrackingWindow: UIWindow { 12 | 13 | override init(frame: CGRect) { 14 | super.init(frame: frame) 15 | commonInit() 16 | } 17 | 18 | override init(windowScene: UIWindowScene) { 19 | super.init(windowScene: windowScene) 20 | commonInit() 21 | } 22 | 23 | required init?(coder: NSCoder) { 24 | super.init(coder: coder) 25 | commonInit() 26 | } 27 | 28 | private func commonInit() { 29 | self.rootViewController = UIHeadGazeViewController() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Vocable/Features/TextEditor/UITraitCollection+isSpeaking.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UITraitCollection+isSpeaking.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 7/31/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | @available(iOS 17.0, *) 13 | private struct SpeakingStateTrait: UITraitDefinition { 14 | static var defaultValue: Bool = false 15 | } 16 | 17 | @available(iOS 17.0, *) 18 | extension UITraitCollection { 19 | var isSpeaking: Bool { 20 | self[SpeakingStateTrait.self] 21 | } 22 | } 23 | 24 | @available(iOS 17.0, *) 25 | extension UIMutableTraits { 26 | var isSpeaking: Bool { 27 | get { self[SpeakingStateTrait.self] } 28 | set { self[SpeakingStateTrait.self] = newValue } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Tests/VocableTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | English 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Vocable/Supporting Files/AXIdentifier/AccessibilityID+Settings+EditCategories.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccessibilityID+Settings+EditCategories.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 5/11/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension AccessibilityID.settings { 12 | public struct editCategories { 13 | public static let addCategoryButton: AccessibilityID = "edit-categories-add-category-button" 14 | public static let categoryButton: AccessibilityID = "edit-categories-category-button" 15 | public static let moveUpButton: AccessibilityID = "edit-categories-move-up-button" 16 | public static let moveDownButton: AccessibilityID = "edit-categories-move-down-button" 17 | private init() {} 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Tests/VocableUITests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Vocable/Common/Views/TrackingContainerViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TrackingContainerViewController.swift 3 | // Vocable AAC 4 | // 5 | // Created by Chris Stroud on 2/3/20. 6 | // Copyright © 2020 WillowTree. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Combine 11 | 12 | final class TrackingContainerViewController: UIViewController { 13 | 14 | private var contentViewController: UIViewController! 15 | private var trackingViewController: UIHeadGazeViewController? 16 | 17 | override func prepare(for segue: UIStoryboardSegue, sender: Any?) { 18 | if segue.identifier == "ContentViewControllerSegue" { 19 | self.contentViewController = segue.destination 20 | } 21 | } 22 | 23 | override var childForHomeIndicatorAutoHidden: UIViewController? { 24 | return contentViewController 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Vocable/Supporting Files/AXIdentifier/AccessibilityID+Settings+EditCategoryDetails.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccessibilityID+Settings+EditCategoryDetails.swift 3 | // Vocable 4 | // 5 | // Created by Rudy Salas on 5/25/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension AccessibilityID.settings { 12 | public struct editCategoryDetails { 13 | public static let renameCategoryButton: AccessibilityID = "category-details-rename-category-button" 14 | public static let showCategoryToggle: AccessibilityID = "category-details-show-category-toggle" 15 | public static let editPhrasesButton: AccessibilityID = "category-details-edit-phrases-button" 16 | public static let removeCategoryButton: AccessibilityID = "category-details-remove-category-button" 17 | private init() {} 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Vocable/Features/Settings/VoiceSettings/VoiceProfileItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VoiceProfileItem.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 4/25/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AVFoundation 11 | 12 | struct VoiceProfileItem: Hashable { 13 | let voice: AVSpeechSynthesisVoice 14 | let isSelected: Bool 15 | let isPlaying: Bool 16 | 17 | func hash(into hasher: inout Hasher) { 18 | voice.identifier.hash(into: &hasher) 19 | isSelected.hash(into: &hasher) 20 | isPlaying.hash(into: &hasher) 21 | } 22 | 23 | static func == (_ lhs: VoiceProfileItem, _ rhs: VoiceProfileItem) -> Bool { 24 | lhs.isSelected == rhs.isSelected && 25 | lhs.isPlaying == rhs.isPlaying && 26 | lhs.voice.identifier == rhs.voice.identifier 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Vocable/HeadTracking/Interpolation/PulseController/FormatterExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FormatterExtensions.swift 3 | // Pulse 4 | // 5 | // Created by Dawid Cieslak on 15/04/2018. 6 | // Copyright © 2018 Dawid Cieslak. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension Formatter { 12 | 13 | /// Creates number formatter with exact fraction digits 14 | /// 15 | /// - Parameter fractionDigits: Number of fraction digits 16 | /// - Returns: Number formatter with decimal format and specified number of fraction digits 17 | static func decimalFormat(fractionDigits: Int) -> NumberFormatter { 18 | let formatter = NumberFormatter() 19 | formatter.minimumFractionDigits = fractionDigits 20 | formatter.maximumFractionDigits = fractionDigits 21 | formatter.numberStyle = .decimal 22 | return formatter 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Tests/UnitTests.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "4FFAE1EC-9CEC-4992-88CE-08AC9C26EFCA", 5 | "name" : "Configuration 1", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | "repeatInNewRunnerProcess" : true, 13 | "targetForVariableExpansion" : { 14 | "containerPath" : "container:Vocable.xcodeproj", 15 | "identifier" : "130C00B320D2AD2A007C3163", 16 | "name" : "Vocable" 17 | }, 18 | "testExecutionOrdering" : "random", 19 | "testRepetitionMode" : "retryOnFailure", 20 | "testTimeoutsEnabled" : true 21 | }, 22 | "testTargets" : [ 23 | { 24 | "target" : { 25 | "containerPath" : "container:Vocable.xcodeproj", 26 | "identifier" : "54D3C41323E37CD40061EF47", 27 | "name" : "VocableTests" 28 | } 29 | } 30 | ], 31 | "version" : 1 32 | } 33 | -------------------------------------------------------------------------------- /Vocable/Supporting Files/AXIdentifier/AccessibilityID+Shared.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccessibilityID+Shared.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 5/11/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension AccessibilityID { 12 | public struct shared { 13 | public static let keyboardButton: AccessibilityID = "shared-keyboard-button" 14 | public static let settingsButton: AccessibilityID = "shared-settings-button" 15 | public static let backButton: AccessibilityID = "shared-back-button" 16 | public static let dismissButton: AccessibilityID = "shared-dismiss-button" 17 | public static let titleLabel: AccessibilityID = "shared-title-label" 18 | public static let emptyStateAddPhraseButton: AccessibilityID = "empty-state-addPhrase-button" 19 | private init() {} 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/crowdin_push.yml: -------------------------------------------------------------------------------- 1 | name: Push Strings to Crowdin 2 | 3 | on: 4 | push: 5 | branches: [ develop ] 6 | workflow_dispatch: 7 | inputs: 8 | test: 9 | description: Just to force the manual trigger 10 | required: false 11 | default: 'trigger' 12 | jobs: 13 | synchronize-with-crowdin: 14 | runs-on: macos-14 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Install Crowdin CLI 20 | run: brew install crowdin 21 | shell: sh 22 | 23 | - name: Upload Sources 24 | run: > 25 | crowdin upload sources 26 | shell: sh 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.CROWDIN_GITHUB_TOKEN }} 29 | CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} 30 | CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} -------------------------------------------------------------------------------- /Vocable/HeadTracking/Interpolation/PulseController/PulseExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PulseExtension.swift 3 | // Pulse 4 | // 5 | // Created by Dawid Cieslak on 14/04/2018. 6 | // Copyright © 2018 Dawid Cieslak. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension Pulse { 12 | 13 | public convenience init( measureClosure: @escaping (() -> CGFloat), outputClosure: @escaping ((_ output: CGFloat) -> Void)) { 14 | let configuration = Pulse.Configuration(minimumValueStep: 0.005, Kp: 1.0, Ki: 0.1, Kd: 0.1) 15 | self.init(configuration: configuration, measureClosure: measureClosure, outputClosure: outputClosure) 16 | } 17 | 18 | public func showTunningView(minimumValue: CGFloat, maximumValue: CGFloat) { 19 | TunningWindow.shared.tuningContainerViewController.addTuningViewController(for: self, minValue: minimumValue, maxValue: maximumValue) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Vocable/Common/PublishedValue.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PublishedValue.swift 3 | // Vocable AAC 4 | // 5 | // Created by Jesse Morgan on 3/26/20. 6 | // Copyright © 2020 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | 12 | @propertyWrapper struct PublishedValue { 13 | 14 | typealias Publisher = AnyPublisher 15 | private let subject: CurrentValueSubject 16 | 17 | var wrappedValue: T { 18 | didSet { 19 | subject.send(wrappedValue) 20 | } 21 | } 22 | 23 | var projectedValue: PublishedValue.Publisher { 24 | mutating get { 25 | return subject.eraseToAnyPublisher() 26 | } 27 | } 28 | 29 | init(wrappedValue: T) { 30 | self.wrappedValue = wrappedValue 31 | self.subject = CurrentValueSubject(self.wrappedValue) 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /Vocable/Supporting Files/AXIdentifier/AccessibilityID+Shared+Alert.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccessibilityID+Shared+Alert.swift 3 | // Vocable 4 | // 5 | // Created by Canan Arikan on 5/27/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension AccessibilityID.shared { 12 | public struct alert { 13 | public static let continueButton: AccessibilityID = "alert-button-continue-editing" 14 | public static let discardButton: AccessibilityID = "alert-button-discard-changes" 15 | public static let deleteButton: AccessibilityID = "alert-button-delete" 16 | public static let cancelButton: AccessibilityID = "alert-button-cancel" 17 | public static let createDuplicateButton: AccessibilityID = "alert-button-create-duplicate" 18 | public static let messageLabel: AccessibilityID = "alert-message" 19 | private init() {} 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: 2 | - trailing_whitespace 3 | - identifier_name 4 | - compiler_protocol_init 5 | - unused_optional_binding 6 | - force_cast 7 | - force_try 8 | - type_name 9 | - large_tuple 10 | opt_in_rules: 11 | - empty_count 12 | - empty_string 13 | included: 14 | - Vocable 15 | excluded: 16 | - Carthage 17 | - Pods 18 | - SwiftLint/Common/3rdPartyLib 19 | line_length: 20 | warning: 250 21 | error: 300 22 | ignores_function_declarations: true 23 | ignores_comments: true 24 | ignores_urls: true 25 | function_body_length: 26 | warning: 300 27 | error: 500 28 | function_parameter_count: 29 | warning: 6 30 | error: 8 31 | type_body_length: 32 | warning: 500 33 | error: 500 34 | file_length: 35 | warning: 1000 36 | error: 1500 37 | ignore_comment_only_lines: true 38 | cyclomatic_complexity: 39 | warning: 15 40 | error: 25 41 | nesting: 42 | type_level: 5 43 | reporter: "xcode" 44 | -------------------------------------------------------------------------------- /Vocable/Extensions/NSDiffableDataSourceSnapshot+Map.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSDiffableDataSourceSnapshot+Map.swift 3 | // Vocable 4 | // 5 | // Created by Jesse Morgan on 4/12/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension NSDiffableDataSourceSnapshot { 12 | func mapItemIdentifier(_ transform: (ItemIdentifierType) -> NewIdentifierType) -> NSDiffableDataSourceSnapshot { 13 | var updatedSnapshot = NSDiffableDataSourceSnapshot() 14 | 15 | updatedSnapshot.appendSections(sectionIdentifiers) 16 | 17 | for sectionId in sectionIdentifiers { 18 | let items = itemIdentifiers(inSection: sectionId).map(transform) 19 | updatedSnapshot.appendItems(items, toSection: sectionId) 20 | } 21 | 22 | return updatedSnapshot 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Tests/VocableUITests/Common/Environment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Environment.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 4/25/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import XCTest 11 | 12 | protocol LaunchEnvironmentEncodable { 13 | func launchEnvironmentValue() -> String 14 | } 15 | 16 | struct Environment: XCTestAppConfigurable { 17 | 18 | private let key: LaunchEnvironment.Key 19 | private let value: String 20 | 21 | init(_ key: LaunchEnvironment.Key, value: String) { 22 | self.key = key 23 | self.value = value 24 | } 25 | 26 | init(_ key: LaunchEnvironment.Key, value: () -> T) where T: LaunchEnvironmentEncodable { 27 | self.key = key 28 | self.value = value().launchEnvironmentValue() 29 | } 30 | 31 | func configure(_ app: XCUIApplication) { 32 | app.launchEnvironment[key.rawValue] = value 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Vocable/Features/Keyboard/Keypad/Layout/Components/KeyboardLayoutElement+Padding.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardLayoutElement+Padding.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 7/31/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension KeyboardLayoutElement { 12 | 13 | func padding( 14 | leading: some KeyboardLayoutRelativeWidth 15 | ) -> some KeyboardLayoutElement { 16 | environment(\.padding.leading, leading) 17 | } 18 | 19 | func padding( 20 | trailing: some KeyboardLayoutRelativeWidth 21 | ) -> some KeyboardLayoutElement { 22 | environment(\.padding.trailing, trailing) 23 | } 24 | 25 | func padding( 26 | leading: some KeyboardLayoutRelativeWidth, 27 | trailing: some KeyboardLayoutRelativeWidth 28 | ) -> some KeyboardLayoutElement { 29 | environment(\.padding, .init(leading: leading, trailing: trailing)) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Vocable/CoreData/NSPersistentContainer+Shared.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSPersistentContainer+Shared.swift 3 | // Vocable AAC 4 | // 5 | // Created by Chris Stroud on 2/21/20. 6 | // Copyright © 2020 WillowTree. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | 11 | extension NSPersistentContainer { 12 | private struct Storage { 13 | static let container: NSPersistentContainer = { 14 | let container = NSPersistentContainer(name: "Phrases") 15 | container.loadPersistentStores { (_, error) in 16 | if let error = error { 17 | assertionFailure("CoreData: Unresolved error \(error.localizedDescription)") 18 | return 19 | } 20 | container.viewContext.automaticallyMergesChangesFromParent = true 21 | } 22 | return container 23 | }() 24 | } 25 | 26 | static var shared: NSPersistentContainer { 27 | return Storage.container 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Vocable/Common/Views/ToastView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PhraseSavedView.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 3/18/20. 6 | // Copyright © 2020 WillowTree. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | @IBDesignable 12 | class ToastView: UIView { 13 | 14 | @IBOutlet private var iconView: UIImageView! 15 | @IBOutlet private var titleLabel: UILabel! 16 | 17 | var text: String = "" { 18 | didSet { 19 | titleLabel?.text = text 20 | } 21 | } 22 | 23 | init() { 24 | super.init(frame: .zero) 25 | commonInit() 26 | } 27 | 28 | override init(frame: CGRect) { 29 | super.init(frame: frame) 30 | commonInit() 31 | } 32 | 33 | required init?(coder aDecoder: NSCoder) { 34 | super.init(coder: aDecoder) 35 | commonInit() 36 | } 37 | 38 | private func commonInit() { 39 | layer.cornerRadius = 30 40 | layer.masksToBounds = true 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Vocable/Features/Settings/SelectionMode/Views/SettingsCollectionViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsCollectionViewCell.swift 3 | // Vocable AAC 4 | // 5 | // Created by Jesse Morgan on 2/26/20. 6 | // Copyright © 2020 WillowTree. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Combine 11 | 12 | final class SettingsCollectionViewCell: VocableCollectionViewCell { 13 | 14 | @IBOutlet private weak var textLabel: UILabel! 15 | @IBOutlet private weak var imageView: UIImageView! 16 | 17 | override func updateContent() { 18 | super.updateContent() 19 | 20 | let disabledColor = isEnabled ? .defaultTextColor : UIColor.defaultTextColor.withAlphaComponent(0.6) 21 | textLabel?.textColor = disabledColor 22 | imageView?.tintColor = disabledColor 23 | } 24 | 25 | func setup(title: String, image: UIImage?) { 26 | guard let image = image else { return } 27 | 28 | textLabel.text = title 29 | imageView.image = image 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /Vocable/Features/Voice/SoundEffect.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Sounds.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 1/5/21. 6 | // Copyright © 2021 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AudioToolbox 11 | 12 | enum SoundEffect: String { 13 | 14 | private struct Storage { 15 | var data = [SoundEffect: Data]() 16 | static var shared = Storage() 17 | } 18 | 19 | case listening = "Listening" 20 | case paused = "Paused" 21 | 22 | var soundData: Data? { 23 | 24 | if let contents = Storage.shared.data[self] { 25 | return contents 26 | } 27 | 28 | guard let url = Bundle.main.url(forResource: rawValue, withExtension: "wav"), 29 | let data = try? Data(contentsOf: url) else { 30 | assertionFailure("File not found for name \"\(rawValue)\" of type .wav") 31 | return nil 32 | } 33 | Storage.shared.data[self] = data 34 | return data 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Vocable/HeadTracking/Interpolation/InterpolableValues.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InterpolableValues.swift 3 | // Vocable AAC 4 | // 5 | // Created by Chris Stroud on 1/31/20. 6 | // Copyright © 2020 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CoreGraphics 11 | 12 | // Extensions of types that we would like to interpolate 13 | // to make them conform to Interpolable 14 | 15 | extension CGPoint: Interpolable { 16 | var interpolableValues: [Double] { 17 | return [Double(x), Double(y)] 18 | } 19 | init(interpolableValues: [Double]) { 20 | self.init(x: CGFloat(interpolableValues[0]), y: CGFloat(interpolableValues[1])) 21 | } 22 | } 23 | 24 | // MARK: - 25 | 26 | extension SIMD2: Interpolable where Scalar == Float { 27 | var interpolableValues: [Double] { 28 | return [Double(self[0]), Double(self[1])] 29 | } 30 | init(interpolableValues: [Double]) { 31 | self.init(Float(interpolableValues[0]), Float(interpolableValues[1])) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Vocable/Features/Settings/TimingSensitivity/Model/CursorSensitivity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Sensitivity.swift 3 | // Vocable AAC 4 | // 5 | // Created by Jesse Morgan on 3/30/20. 6 | // Copyright © 2020 WillowTree. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | enum CursorSensitivity: Int, Codable, AnalyticsReportable { 12 | 13 | case low 14 | case medium 15 | case high 16 | 17 | // The minimum/maximum values to scale how quickly the cursor moves around the screen 18 | var range: ClosedRange { 19 | switch self { 20 | case .low: 21 | return (2.0 ... 4.0) 22 | case .medium: 23 | return (3.0 ... 5.0) 24 | case .high: 25 | return (4.0 ... 6.5) 26 | } 27 | } 28 | 29 | var analyticsDescription: String { 30 | switch self { 31 | case .low: 32 | return "low" 33 | case .medium: 34 | return "medium" 35 | case .high: 36 | return "high" 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Tests/VocableUITests/Screens/TimingAndSensitivityScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimingAndSensitivityScreen.swift 3 | // VocableUITests 4 | // 5 | // Created by Alex Facer on 7/1/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import XCTest 11 | 12 | class TimingAndSensitivityScreen: BaseScreen { 13 | 14 | static let lowButton = XCUIApplication().buttons[.settings.timingAndSensitivity.lowSensitivityButton] 15 | static let mediumButton = XCUIApplication().buttons[.settings.timingAndSensitivity.mediumSensitivityButton] 16 | static let highButton = XCUIApplication().buttons[.settings.timingAndSensitivity.highSensitivityButton] 17 | static let reduceHoverTimeButton = XCUIApplication().buttons[.settings.timingAndSensitivity.decreaseHoverTimeButton] 18 | static let increaseHoverTimeButton = XCUIApplication().buttons[.settings.timingAndSensitivity.increaseHoverTimeButton] 19 | static let hoverTimeLabel = XCUIApplication().staticTexts[.settings.timingAndSensitivity.hoverTimeLabel] 20 | 21 | } 22 | -------------------------------------------------------------------------------- /Vocable/HeadTracking/UIHeadGazeCursorWindow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIHeadGazeCursorWindow.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 4/24/20. 6 | // Copyright © 2020 WillowTree. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class UIHeadGazeCursorWindow: UIWindow { 12 | 13 | var cursorViewController: UIVirtualCursorViewController { 14 | return rootViewController as! UIVirtualCursorViewController 15 | } 16 | 17 | override init(frame: CGRect) { 18 | super.init(frame: frame) 19 | commonInit() 20 | } 21 | 22 | override init(windowScene: UIWindowScene) { 23 | super.init(windowScene: windowScene) 24 | commonInit() 25 | } 26 | 27 | required init?(coder: NSCoder) { 28 | super.init(coder: coder) 29 | commonInit() 30 | } 31 | 32 | private func commonInit() { 33 | self.rootViewController = UIVirtualCursorViewController() 34 | self.isUserInteractionEnabled = false 35 | self.backgroundColor = .clear 36 | self.isOpaque = false 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Vocable/Features/Keyboard/Keypad/Layout/Components/KeyboardBody.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardBody.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 7/31/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct KeyboardBody { 12 | private(set) var root: any KeyboardLayoutElement 13 | 14 | init(root: any KeyboardLayoutElement) { 15 | self.root = root 16 | var baseIndex = 0 17 | KeyboardBody.rebaseElement(&self.root, baseRowIndex: &baseIndex) 18 | } 19 | 20 | private static func rebaseElement( 21 | _ element: inout any KeyboardLayoutElement, 22 | baseRowIndex: inout Int 23 | ) { 24 | if let row = element as? KeyboardLayoutRow { 25 | element = row.withIndex(baseRowIndex) 26 | baseRowIndex += 1 27 | } 28 | for index in element.children.indices { 29 | rebaseElement( 30 | &element.children[index], 31 | baseRowIndex: &baseRowIndex 32 | ) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Vocable/Features/Keyboard/SuggestionsUI/AnimationContainerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnimationContainerView.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 7/31/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | class AnimationContainerView: UIView { 13 | 14 | let child: T 15 | 16 | init(child: T) { 17 | self.child = child 18 | super.init(frame: .zero) 19 | commonInit() 20 | } 21 | 22 | required init?(coder: NSCoder) { 23 | fatalError() 24 | } 25 | 26 | private func commonInit() { 27 | child.translatesAutoresizingMaskIntoConstraints = false 28 | addSubview(child) 29 | 30 | NSLayoutConstraint.activate([ 31 | child.topAnchor.constraint(equalTo: topAnchor), 32 | child.bottomAnchor.constraint(equalTo: bottomAnchor), 33 | child.leadingAnchor.constraint(equalTo: leadingAnchor), 34 | child.trailingAnchor.constraint(equalTo: trailingAnchor) 35 | ]) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Vocable/SwiftUI/GazeBlocking.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GazeBlocking.swift 3 | // Vocable 4 | // 5 | // Created by Robert Moyer on 5/16/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | extension View { 12 | /// Prevents any gaze events from being passed to views beneath this one 13 | /// 14 | /// This can be useful when presenting a sheet or a full-screen cover on 15 | /// top of views that are capable of receiving gaze events. 16 | func gazeBlocking() -> some View { 17 | modifier(GazeBlocking()) 18 | } 19 | } 20 | 21 | private struct GazeBlocking: ViewModifier { 22 | func body(content: Content) -> some View { 23 | content.overlay(EatGaze()) 24 | } 25 | } 26 | 27 | /// A view that does not allow gazes to pass through it 28 | private struct EatGaze: UIViewRepresentable { 29 | func makeUIView(context: Context) -> GazeEatingView { 30 | return GazeEatingView() 31 | } 32 | 33 | func updateUIView( 34 | _ uiView: GazeEatingView, 35 | context: Context 36 | ) { /* No op */ } 37 | } 38 | -------------------------------------------------------------------------------- /Vocable/Extensions/NSPredicate+Operators.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSPredicate+Operators.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 3/14/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension NSPredicate { 12 | 13 | static func && (_ lhs: NSPredicate, _ rhs: NSPredicate) -> NSPredicate { 14 | NSCompoundPredicate(andPredicateWithSubpredicates: [lhs, rhs]) 15 | } 16 | 17 | static func || (_ lhs: NSPredicate, _ rhs: NSPredicate) -> NSPredicate { 18 | NSCompoundPredicate(orPredicateWithSubpredicates: [lhs, rhs]) 19 | } 20 | 21 | static prefix func ! (_ lhs: NSPredicate) -> NSPredicate { 22 | NSCompoundPredicate(notPredicateWithSubpredicate: lhs) 23 | } 24 | 25 | static func &= (_ lhs: inout NSPredicate, _ rhs: NSPredicate) { 26 | lhs = NSCompoundPredicate(andPredicateWithSubpredicates: [lhs, rhs]) 27 | } 28 | 29 | static func |= (_ lhs: inout NSPredicate, _ rhs: NSPredicate) { 30 | lhs = NSCompoundPredicate(orPredicateWithSubpredicates: [lhs, rhs]) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Vocable/Extensions/TextSuggestionController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextSuggestionController.swift 3 | // Vocable AAC 4 | // 5 | // Created by Kyle Ohanian on 4/15/19. 6 | // Copyright © 2019 WillowTree. All rights reserved. 7 | // 8 | import UIKit 9 | 10 | class TextSuggestionController { 11 | 12 | private let checker = UITextChecker() 13 | 14 | func suggestions(for expression: TextExpression) -> [String] { 15 | let fullExpression = expression.value 16 | let lastWord = expression.lastWord() ?? "" 17 | let range = NSRange(location: (fullExpression as NSString).length - (lastWord as NSString).length, length: (lastWord as NSString).length) 18 | var joinedArray: [String] = [] 19 | let guesses = checker.guesses(forWordRange: range, in: fullExpression, language: Locale.current.identifier) ?? [] 20 | let completions = checker.completions(forPartialWordRange: range, in: fullExpression, language: Locale.current.identifier) ?? [] 21 | joinedArray.append(contentsOf: completions) 22 | joinedArray.append(contentsOf: guesses) 23 | return joinedArray 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 WillowTree, LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Vocable/Supporting Files/AXIdentifier/AccessibilityID+Settings+TimingSensitivity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccessibilityID+Settings+TimingSensitivity.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 5/11/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension AccessibilityID.settings { 12 | public struct timingAndSensitivity { 13 | public static let decreaseHoverTimeButton: AccessibilityID = "timing-and-sensitivity-decrease-hover-time-button" 14 | public static let increaseHoverTimeButton: AccessibilityID = "timing-and-sensitivity-increase-hover-time-button" 15 | public static let lowSensitivityButton: AccessibilityID = "timing-and-sensitivity-low-sensitivity-button" 16 | public static let mediumSensitivityButton: AccessibilityID = "timing-and-sensitivity-medium-sensitivity-button" 17 | public static let highSensitivityButton: AccessibilityID = "timing-and-sensitivity-high-sensitivity-button" 18 | public static let hoverTimeLabel: AccessibilityID = "timing-and-sensitivity-hover-time-label" 19 | private init() {} 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Vocable/Common/VocableNavigationController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VocableNavigationController.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 4/30/20. 6 | // Copyright © 2020 WillowTree. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class VocableNavigationController: UINavigationController { 12 | 13 | required init?(coder aDecoder: NSCoder) { 14 | super.init(coder: aDecoder) 15 | commonInit() 16 | } 17 | override init(rootViewController: UIViewController) { 18 | super.init(rootViewController: rootViewController) 19 | commonInit() 20 | } 21 | 22 | override init(navigationBarClass: AnyClass?, toolbarClass: AnyClass?) { 23 | super.init(navigationBarClass: navigationBarClass, toolbarClass: toolbarClass) 24 | commonInit() 25 | } 26 | 27 | override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { 28 | super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) 29 | commonInit() 30 | } 31 | 32 | private func commonInit() { 33 | modalPresentationStyle = .fullScreen 34 | setNavigationBarHidden(true, animated: false) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/on_pull_request.yml: -------------------------------------------------------------------------------- 1 | name: On pull request 2 | on: 3 | workflow_dispatch: 4 | pull_request: 5 | branches: 6 | - develop 7 | 8 | jobs: 9 | on_pull_request: 10 | runs-on: macos-14 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - name: Install SSH key 15 | uses: shimataro/ssh-key-action@v2 16 | with: 17 | key: ${{ secrets.FASTLANE_MATCH_GIT_PRIVATE_KEY }} 18 | known_hosts: ${{ secrets.MATCH_SSH_KNOWN_HOSTS }} 19 | name: id_rsa_match 20 | if_key_exists: replace 21 | 22 | - name: Set up Ruby environment 23 | uses: ruby/setup-ruby@v1 24 | with: 25 | ruby-version: 3.3.1 26 | 27 | - name: Install bundler 28 | run: gem install bundler 29 | 30 | - name: Install dependencies 31 | run: bundle install 32 | 33 | - name: Install SwiftLint 34 | run: brew install swiftlint 35 | 36 | - name: Run Unit and UI tests 37 | uses: ./.github/workflows/actions/run_lane 38 | with: 39 | lane: test_unit_ui 40 | match_password: ${{ secrets.MATCH_PASSWORD }} 41 | -------------------------------------------------------------------------------- /Vocable/Features/Keyboard/SuggestionsUI/KeyboardSuggestionAnimationContainer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardSuggestionAnimationContainer.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 7/31/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | extension KeyboardSuggestionsView { 13 | final class SuggestionAnimationContainer: AnimationContainerView { 14 | 15 | typealias ScaleContainer = AnimationContainerView 16 | typealias OpacityContainer = AnimationContainerView 17 | 18 | let button: SuggestionButton 19 | let scaleContainer: ScaleContainer 20 | let opacityContainer: OpacityContainer 21 | 22 | init() { 23 | self.button = SuggestionButton() 24 | self.scaleContainer = AnimationContainerView(child: button) 25 | self.opacityContainer = AnimationContainerView(child: scaleContainer) 26 | super.init(child: opacityContainer) 27 | } 28 | 29 | required init?(coder: NSCoder) { 30 | fatalError("init(coder:) has not been implemented") 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Vocable/Extensions/UIFont+Helpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIFont+Helpers.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 5/17/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | extension UIFont { 13 | 14 | static func settingsCellTitle() -> UIFont { 15 | UIFont.systemFont(ofSize: 22, weight: .bold) 16 | } 17 | 18 | static func textEditor( 19 | satisfying traitCollection: UITraitCollection = .current 20 | ) -> UIFont { 21 | let fontSize: CGFloat = (traitCollection.sizeClass == .hRegular_vRegular) ? 40 : 28 22 | let desiredFont = UIFont.systemFont(ofSize: fontSize, weight: .bold) 23 | return desiredFont 24 | } 25 | 26 | static func keyboardKey( 27 | satisfying traitCollection: UITraitCollection = .current 28 | ) -> UIFont { 29 | return switch traitCollection.sizeClass { 30 | case .hCompact_vCompact, .hRegular_vCompact: 31 | .boldSystemFont(ofSize: 28) 32 | case .hCompact_vRegular: 33 | .boldSystemFont(ofSize: 22) 34 | default: 35 | .boldSystemFont(ofSize: 48) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Vocable/SwiftUI/Gaze Button/ButtonState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ButtonState.swift 3 | // Vocable 4 | // 5 | // Created by Robert Moyer on 4/7/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | /// Constants describing the state of a button 10 | struct ButtonState: OptionSet { 11 | let rawValue: UInt 12 | 13 | /// The normal or default state of a button, neither selected nor highlighted 14 | static let normal = Self([]) 15 | 16 | /// Highlighted state of a button 17 | /// 18 | /// A button becomes highlighted when a touch or gaze event 19 | /// enters the button's bounds, and it loses that highlight 20 | /// when there is a touch-up event or when the touch/gaze event 21 | /// exits the button's bounds. 22 | static let highlighted = Self(rawValue: 1 << 0) 23 | 24 | /// Selected state of a button 25 | /// 26 | /// A button becomes selected when a gaze event remains within 27 | /// the button's bounds for the supplied `minimumGazeDuration`. 28 | /// It loses its selection when the gaze eventis cancelled, ended, 29 | /// or when the gaze exits the button's bounds. 30 | static let selected = Self(rawValue: 1 << 1) 31 | } 32 | -------------------------------------------------------------------------------- /Vocable/Features/Keyboard/Keypad/Layout/Components/KeyboardLayoutRelativeWidth/KeyboardLayoutRelativeWidth.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardLayoutRelativeWidth.swift 3 | // 4 | // 5 | // Created by Chris Stroud on 6/17/24. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol KeyboardLayoutRelativeWidth: Hashable { 11 | func value( 12 | in containerWidth: CGFloat, 13 | environment: KeyboardLayoutEnvironment 14 | ) -> CGFloat 15 | 16 | static prefix func - (_ value: Self) -> Self 17 | } 18 | 19 | func - (lhs: any KeyboardLayoutRelativeWidth, rhs: any KeyboardLayoutRelativeWidth) -> KeyboardLayoutRelativeWidthCompound { 20 | .compound(lhs, -AnyKeyboardLayoutRelativeWidth(rhs)) 21 | } 22 | 23 | func -= (lhs: inout any KeyboardLayoutRelativeWidth, rhs: any KeyboardLayoutRelativeWidth) { 24 | lhs = .compound(lhs, -AnyKeyboardLayoutRelativeWidth(rhs)) 25 | } 26 | 27 | func + (lhs: any KeyboardLayoutRelativeWidth, rhs: any KeyboardLayoutRelativeWidth) -> KeyboardLayoutRelativeWidthCompound { 28 | .compound(lhs, rhs) 29 | } 30 | 31 | func += (lhs: inout any KeyboardLayoutRelativeWidth, rhs: any KeyboardLayoutRelativeWidth) { 32 | lhs = KeyboardLayoutRelativeWidthCompound(lhs: lhs, rhs: rhs) 33 | } 34 | -------------------------------------------------------------------------------- /Vocable/Features/Keyboard/Keypad/Layout/Components/KeyboardLayoutElement+Sizing.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardLayoutElement+Sizing.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 7/31/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension KeyboardLayoutElement { 12 | func keyWidth( 13 | count: Int, 14 | span: Int? = nil 15 | ) -> some KeyboardLayoutElement { 16 | self.mutatingEnvironment { environment in 17 | environment.columnCount = count 18 | if let span { 19 | environment.spanCount = span 20 | } 21 | environment.keySizingStrategy = .columns 22 | } 23 | } 24 | 25 | func keyWidth( 26 | span: Int 27 | ) -> some KeyboardLayoutElement { 28 | self.mutatingEnvironment { environment in 29 | environment.spanCount = span 30 | } 31 | } 32 | 33 | func keyWidth( 34 | proportional multiplier: CGFloat 35 | ) -> some KeyboardLayoutElement { 36 | self.mutatingEnvironment { environment in 37 | environment.keySizingStrategy = .proportional(multiplier: multiplier) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Vocable/Common/CategoryReordering/CategoryReordering.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CategoryReordering.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 3/29/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct CategoryOrderabilityModel { 12 | 13 | private let upwardOrderableIndices: Range 14 | private let downwardOrderableIndices: Range 15 | 16 | init(categories: T) { 17 | let lowestDownwardOrderableIndex = categories.index(categories.startIndex, offsetBy: 1, limitedBy: categories.endIndex) ?? categories.endIndex 18 | downwardOrderableIndices = lowestDownwardOrderableIndex ..< categories.endIndex 19 | 20 | let highestUpwardOrderableIndex = categories.index(categories.endIndex, offsetBy: -1, limitedBy: categories.startIndex) ?? categories.startIndex 21 | upwardOrderableIndices = categories.startIndex ..< highestUpwardOrderableIndex 22 | } 23 | 24 | func canMoveToHigherIndex(from index: T.Index) -> Bool { 25 | upwardOrderableIndices.contains(index) 26 | } 27 | 28 | func canMoveToLowerIndex(from index: T.Index) -> Bool { 29 | downwardOrderableIndices.contains(index) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Tests/UITests.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "77005B39-8254-403F-B2F5-DC55FF074BF6", 5 | "name" : "Configuration 1", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | "codeCoverage" : false, 13 | "commandLineArgumentEntries" : [ 14 | 15 | ], 16 | "environmentVariableEntries" : [ 17 | { 18 | "enabled" : false, 19 | "key" : "mixpanelToken", 20 | "value" : "Do not commit a real token" 21 | }, 22 | { 23 | "key" : "IDEPreferLogStreaming", 24 | "value" : "YES" 25 | } 26 | ], 27 | "repeatInNewRunnerProcess" : true, 28 | "targetForVariableExpansion" : { 29 | "containerPath" : "container:Vocable.xcodeproj", 30 | "identifier" : "130C00B320D2AD2A007C3163", 31 | "name" : "Vocable" 32 | }, 33 | "testExecutionOrdering" : "random", 34 | "testRepetitionMode" : "retryOnFailure", 35 | "testTimeoutsEnabled" : true 36 | }, 37 | "testTargets" : [ 38 | { 39 | "target" : { 40 | "containerPath" : "container:Vocable.xcodeproj", 41 | "identifier" : "17191A2E245089270079F9BE", 42 | "name" : "VocableUITests" 43 | } 44 | } 45 | ], 46 | "version" : 1 47 | } 48 | -------------------------------------------------------------------------------- /Vocable/Common/CarouselGridLayout+Masks.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CarouselGridLayout+Masks.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 4/17/20. 6 | // Copyright © 2020 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct CellSeparatorMask: OptionSet { 12 | let rawValue: Int 13 | 14 | static let top = CellSeparatorMask(rawValue: 1 << 0) 15 | static let bottom = CellSeparatorMask(rawValue: 1 << 1) 16 | static let both: CellSeparatorMask = [.top, .bottom] 17 | } 18 | 19 | struct CellOrdinalButtonMask: OptionSet { 20 | let rawValue: Int 21 | 22 | static let topUpArrow = CellOrdinalButtonMask(rawValue: 1 << 0) 23 | static let bottomDownArrow = CellOrdinalButtonMask(rawValue: 1 << 1) 24 | static let both: CellOrdinalButtonMask = [.topUpArrow, .bottomDownArrow] 25 | static let none: CellOrdinalButtonMask = [] 26 | } 27 | 28 | extension CarouselGridLayout { 29 | 30 | func separatorMask(for indexPath: IndexPath) -> CellSeparatorMask { 31 | let index = indexPath.item 32 | let rowIndexWithinPage = Int((index % itemsPerPage) / columnCount) 33 | 34 | if rowIndexWithinPage == 0 { 35 | return .both 36 | } else { 37 | return .bottom 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Vocable/Supporting Files/AXIdentifier/AccessibilityID+Settings.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccessibilityID+Settings.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 5/11/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension AccessibilityID { 12 | public struct settings { 13 | public static let categoriesAndPhrasesCell: AccessibilityID = "settings-categories-and-phrases-cell" 14 | public static let timingAndSensitivityCell: AccessibilityID = "settings-timing-and-sensitivity-cell" 15 | public static let resetAppSettingsCell: AccessibilityID = "settings-reset-app-settings-cell" 16 | public static let listeningModeCell: AccessibilityID = "settings-listening-mode-cell" 17 | public static let selectionModeCell: AccessibilityID = "settings-selection-mode-cell" 18 | public static let privacyPolicyCell: AccessibilityID = "settings-privacy-policy-cell" 19 | public static let contactDevelopersCell: AccessibilityID = "settings-contact-developers-cell" 20 | public static let voiceSettingsCell: AccessibilityID = "settings-voice-settings-cell" 21 | public static let keyboardLayoutCell: AccessibilityID = "settings-keyboard-layout-cell" 22 | private init() {} 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Vocable/CoreData/ModelObjectExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ModelObjectExtensions.swift 3 | // Vocable AAC 4 | // 5 | // Created by Chris Stroud on 2/21/20. 6 | // Copyright © 2020 WillowTree. All rights reserved. 7 | // 8 | import CoreData 9 | import Foundation 10 | 11 | extension Category: NSManagedObjectIdentifiable { 12 | typealias IdentifierType = String 13 | 14 | public override func awakeFromInsert() { 15 | super.awakeFromInsert() 16 | setPrimitiveValue(Date(), forKey: #keyPath(creationDate)) 17 | setPrimitiveValue(false, forKey: #keyPath(isUserGenerated)) 18 | setPrimitiveValue(Int32.max, forKey: #keyPath(ordinal)) 19 | setPrimitiveValue(false, forKey: #keyPath(isUserRemoved)) 20 | setPrimitiveValue(false, forKey: #keyPath(isUserRenamed)) 21 | } 22 | } 23 | 24 | extension Phrase: NSManagedObjectIdentifiable { 25 | typealias IdentifierType = String 26 | 27 | public override func awakeFromInsert() { 28 | super.awakeFromInsert() 29 | setPrimitiveValue(Date(), forKey: #keyPath(creationDate)) 30 | setPrimitiveValue(false, forKey: #keyPath(isUserGenerated)) 31 | setPrimitiveValue(false, forKey: #keyPath(isUserRemoved)) 32 | setPrimitiveValue(false, forKey: #keyPath(isUserRenamed)) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Vocable/Common/NSAttributedString+Helpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSAttributedString+Helpers.swift 3 | // Vocable 4 | // 5 | // Created by Jesse Morgan on 4/8/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension NSAttributedString { 12 | 13 | static func imageAttachedString(for string: String, with image: UIImage, attributes: [Key: Any]? = nil) -> NSAttributedString { 14 | let isRightToLeftLayout = UITraitCollection.current.layoutDirection == .rightToLeft 15 | 16 | let formatString: String = isRightToLeftLayout ? 17 | .localizedStringWithFormat("%@ ", string) : 18 | .localizedStringWithFormat(" %@", string) 19 | 20 | let text = NSMutableAttributedString(string: formatString, attributes: attributes) 21 | let attachment = NSMutableAttributedString(attachment: NSTextAttachment(image: image)) 22 | if let attributes = attributes { 23 | attachment.addAttributes(attributes, range: .entireRange(of: attachment.string)) 24 | } 25 | 26 | let textRange = NSRange(of: text.string) 27 | let insertionIndex = isRightToLeftLayout ? textRange.upperBound : textRange.lowerBound 28 | 29 | text.insert(attachment, at: insertionIndex) 30 | return text 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /Vocable/GoogleService-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CLIENT_ID 6 | 292071094533-86uue52ck8hejnsh3unfoccvk5g7svnl.apps.googleusercontent.com 7 | REVERSED_CLIENT_ID 8 | com.googleusercontent.apps.292071094533-86uue52ck8hejnsh3unfoccvk5g7svnl 9 | API_KEY 10 | AIzaSyCeSvWmH6Z8O3fnxxmUH9Wl64HfeJeqzz8 11 | GCM_SENDER_ID 12 | 292071094533 13 | PLIST_VERSION 14 | 1 15 | BUNDLE_ID 16 | com.willowtreeapps.eyespeakaac 17 | PROJECT_ID 18 | vocable-fcb07 19 | STORAGE_BUCKET 20 | vocable-fcb07.appspot.com 21 | IS_ADS_ENABLED 22 | 23 | IS_ANALYTICS_ENABLED 24 | 25 | IS_APPINVITE_ENABLED 26 | 27 | IS_GCM_ENABLED 28 | 29 | IS_SIGNIN_ENABLED 30 | 31 | GOOGLE_APP_ID 32 | 1:292071094533:ios:958a5ecf0d4fcabb909f6c 33 | DATABASE_URL 34 | https://vocable-fcb07.firebaseio.com 35 | 36 | -------------------------------------------------------------------------------- /Tests/VocableUITests/XCUIElement.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XCUIElement.swift 3 | // VocableUITests 4 | // 5 | // Created by Rudy Salas on 4/15/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import XCTest 11 | 12 | extension XCUIElement { 13 | 14 | /// Waits the amount of time you specify for the element’s exists property to become true and then taps on it. 15 | /// 16 | /// Returns false if the timeout expires without the element coming into existence. In this case, the `tap` action 17 | /// will not occur. 18 | func tapWhenExists( 19 | timeout: TimeInterval = 0.5, 20 | file: StaticString = #file, 21 | line: UInt = #line 22 | ) throws { 23 | try assertExistence(timeout: timeout, file: file, line: line) 24 | self.tap() 25 | } 26 | 27 | func assertExistence( 28 | timeout: TimeInterval = 0.5, 29 | _ message: String? = nil, 30 | file: StaticString = #file, 31 | line: UInt = #line 32 | ) throws { 33 | guard self.waitForExistence(timeout: timeout) else { 34 | let message = message ?? "Element did not come into existence before timeout." 35 | XCTFail(message, file: file, line: line) 36 | throw XCTestError(.timeoutWhileWaiting) 37 | } 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /Vocable/Extensions/Phrase+Helpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Phrase+Helpers.swift 3 | // Vocable AAC 4 | // 5 | // Created by Jesse Morgan on 3/18/20. 6 | // Copyright © 2020 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CoreData 11 | 12 | extension Phrase { 13 | 14 | var analyticsName: String? { 15 | isUserGenerated ? "Custom Phrase" : utterance 16 | } 17 | 18 | var isAnalyticsReportable: Bool { 19 | !isUserRenamed && !isUserGenerated 20 | } 21 | 22 | static func create(withUserEntry text: String, in context: NSManagedObjectContext) throws -> Phrase { 23 | let userFavorites = try Category.fetch(.userFavorites, in: context) 24 | return create(withUserEntry: text, category: userFavorites, in: context) 25 | } 26 | 27 | static func create(withUserEntry text: String, category: Category, in context: NSManagedObjectContext) -> Phrase { 28 | let newIdentifier = "user_\(UUID().uuidString)" 29 | let phrase = Phrase.fetchOrCreate(in: context, matching: newIdentifier) 30 | phrase.isUserGenerated = true 31 | phrase.creationDate = Date() 32 | phrase.utterance = text 33 | phrase.languageCode = AppConfig.activePreferredLanguageCode 34 | phrase.category = category 35 | category.addToPhrases(phrase) 36 | return phrase 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /Vocable/Features/Keyboard/Keypad/UI/KeyboardKeyAction+Representation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardKeyAction.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 7/31/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | extension KeyboardKeyAction { 13 | 14 | enum Representation: Hashable { 15 | case image(UIImage) 16 | case string(String) 17 | } 18 | 19 | var representation: Representation { 20 | return switch self { 21 | case .insertCharacter(let value): 22 | .string(String(value)) 23 | case .clear: 24 | .image(UIImage(systemName: "trash")!) 25 | case .backspace: 26 | .image(UIImage(systemName: "delete.left")!) 27 | case .space: 28 | .image(UIImage(systemName: "space")!) 29 | case .speak: 30 | .image(UIImage(systemName: "person.wave.2.fill")!) 31 | case .numberPad: 32 | .image(UIImage(systemName: "textformat.123")!) 33 | case .alphabet: 34 | .image(UIImage(systemName: "abc")!) 35 | case .openModifierPicker, .closeModifierPicker: 36 | .image(UIImage(systemName: "ellipsis")!) 37 | case .beginModifier(let value), .endModifier(let value): 38 | .string("\u{25CC}\(value)") 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Vocable/HeadTracking/Extensions.swift: -------------------------------------------------------------------------------- 1 | import simd 2 | 3 | /* 4 | * The transformation in ARKit is column-major, which means for transformation matrix m, 5 | * m[i] is the ith column of the matrix, and m[i][j] is the elements in the ith column and jth row 6 | * In addition, (m[3][0], m[3][1], m[3][2], m[3][3]) holds the translation amount in x, y, and z direction respectively. 7 | */ 8 | extension matrix_float4x4: CustomStringConvertible { 9 | public var debugDescription: String { 10 | let m = self 11 | return """ 12 | \(m[0][0]), \(m[1][0]), \(m[2][0]), \(m[3][0]) 13 | \(m[0][1]), \(m[1][1]), \(m[2][1]), \(m[3][1]) 14 | \(m[0][2]), \(m[1][2]), \(m[2][2]), \(m[3][2]) 15 | \(m[0][3]), \(m[1][3]), \(m[2][3]), \(m[3][3]) 16 | """ 17 | } 18 | public var description: String { 19 | return self.debugDescription 20 | } 21 | public static let identity: matrix_float4x4 = matrix_float4x4(columns: (simd_float4(1, 0, 0, 0), simd_float4(0, 1, 0, 0), simd_float4(0, 0, 1, 0), simd_float4(0, 0, 0, 1))) 22 | } 23 | 24 | extension BinaryInteger { 25 | var degreesToRadians: Float { 26 | return Float(Int(self)) * .pi / 180.0 27 | } 28 | } 29 | 30 | extension FloatingPoint { 31 | var degreesToRadians: Self { return self * .pi / 180 } 32 | var radiansToDegrees: Self { return self * 180 / .pi } 33 | } 34 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Vocable Contributing Guidelines 2 | 3 | 🥳🎉 Thank you for contributing to Vocable! 🎉🥳 4 | 5 | Contributions from the open-source community are crucial in helping us meet make the best AAC app possible. 6 | 7 | # How to Contribute 8 | 9 | ### Reporting Bugs 10 | - Check the existing issues to make sure the bug has not already been reported. 11 | - Open an issue describing the bug. 12 | - Be sure to use a descriptive title and include detailed steps on how to reproduce the issue. 13 | - Explain the expected behavior vs. the actual behavior. 14 | - Include the version number. 15 | - Tag the issue with the `bug` label. 16 | 17 | 18 | ### Suggesting Enhancements 19 | - Open an issue clearly describing the behavior you would like to see. 20 | - If applicable, provide a few examples of how the feature would work and what purpose it would serve. 21 | - Tag the issue with the `enhancement` label. 22 | 23 | ### Contributing Code by Opening a Pull Request 24 | To contribute to Vocable, please fork the GitHub repo and submit a pull request to the `develop` branch. 25 | 26 | Our code owners will review your work and merge once any issues have been addressed. If the PR gets too outdated we may ask you to rebase and force push to update the PR. 27 | 28 | That's it! Our team will merge your PR, and your changes will be included in the next app version. Thanks for your contribution! 💯 -------------------------------------------------------------------------------- /Vocable/Features/Keyboard/Keypad/Layout/Components/KeyboardLayoutPreviewView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardLayoutPreviewView.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 7/31/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | import UIKit 12 | 13 | @available(iOS 17.0, *) 14 | private struct KeyboardViewRepresentable: UIViewRepresentable { 15 | 16 | let layout: Layout 17 | private(set) var mode: KeyboardLayoutMode = .alphabetical 18 | 19 | func makeUIView(context: Context) -> KeyboardView { 20 | KeyboardView(previewLayout: layout) 21 | } 22 | 23 | func updateUIView(_ uiView: KeyboardView, context: Context) { 24 | uiView.mode = mode 25 | } 26 | } 27 | 28 | struct KeyboardLayoutPreviewView: View { 29 | 30 | let layout: Layout 31 | private(set) var mode: KeyboardLayoutMode = .alphabetical 32 | 33 | var body: some View { 34 | if #available(iOS 17.0, *) { 35 | KeyboardViewRepresentable(layout: layout, mode: mode) 36 | .safeAreaPadding(.all) 37 | .background(Color(uiColor: .collectionViewBackgroundColor)) 38 | } else { 39 | Text(verbatim: "Preview requires iOS 17.0") 40 | .font(.title) 41 | .frame(maxWidth: .infinity, maxHeight: .infinity) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Vocable/Features/Keyboard/Keypad/Layout/Components/KeyboardLayoutRelativeWidth/KeyboardLayoutRelativeWidthFixed.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardLayoutRelativeWidthFixed.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 7/31/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct KeyboardLayoutRelativeWidthFixed: KeyboardLayoutRelativeWidth { 12 | let value: CGFloat 13 | 14 | init(value: CGFloat) { 15 | self.value = value 16 | } 17 | 18 | func value( 19 | in containerWidth: CGFloat, 20 | environment: KeyboardLayoutEnvironment 21 | ) -> CGFloat { 22 | self.value * signMultiplier() 23 | } 24 | 25 | private var sign: FloatingPointSign = .plus 26 | 27 | private func signMultiplier() -> CGFloat { 28 | switch sign { 29 | case .plus: 1.0 30 | case .minus: -1.0 31 | } 32 | } 33 | 34 | static prefix func - (value: Self) -> Self { 35 | var value = value 36 | switch value.sign { 37 | case .plus: 38 | value.sign = .minus 39 | case .minus: 40 | value.sign = .plus 41 | } 42 | return value 43 | } 44 | } 45 | 46 | extension KeyboardLayoutRelativeWidth where Self == KeyboardLayoutRelativeWidthFixed { 47 | 48 | static func fixed(_ value: CGFloat) -> Self { 49 | KeyboardLayoutRelativeWidthFixed(value: value) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Vocable/SwiftUI/Gaze Button/DefaultGazeButtonStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DefaultGazeButtonStyle.swift 3 | // Vocable 4 | // 5 | // Created by Robert Moyer on 4/7/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// The default button style. 12 | /// 13 | /// You can also use ``GazeButtonStyle/automatic`` to construct this style. 14 | struct DefaultGazeButtonStyle: GazeButtonStyle { 15 | func makeBody(_ configuration: Configuration) -> some View { 16 | _Body(configuration) 17 | } 18 | 19 | private struct _Body: View { 20 | @Environment(\.isEnabled) 21 | private var isEnabled 22 | 23 | @Binding var state: ButtonState 24 | 25 | var label: Label 26 | var buttonRole: ButtonRole? 27 | 28 | init(_ configuration: Configuration) where Configuration.Label == Label { 29 | self._state = configuration.state 30 | self.label = configuration.label 31 | self.buttonRole = configuration.role 32 | } 33 | 34 | var body: some View { 35 | label 36 | .foregroundColor(buttonRole == .destructive ? .red : .accentColor) 37 | .opacity( 38 | (state.contains(.highlighted) || !isEnabled) ? 0.5 : 1 39 | ) 40 | } 41 | } 42 | } 43 | 44 | extension GazeButtonStyle where Self == DefaultGazeButtonStyle { 45 | /// The default button style 46 | static var automatic: DefaultGazeButtonStyle { .init() } 47 | } 48 | -------------------------------------------------------------------------------- /Tests/VocableUITests/Tests/PaginationBaseTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomCategoriesBaseTest.swift 3 | // VocableUITests 4 | // 5 | // Created by Rudy Salas and Canan Arikan on 5/06/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | class PaginationBaseTest: XCTestCase { 12 | 13 | static private func phrases(count: Int) -> [Phrase] { 14 | (1...count).map { _ in 15 | Phrase("Hello") 16 | } 17 | } 18 | 19 | let eightPhrasesCategory = Category(id: "first_category", "Category 1", phrases: PaginationBaseTest.phrases(count: 8)) 20 | let ninePhrasesCategory = Category(id: "second_category", "Category 2", phrases: PaginationBaseTest.phrases(count: 9)) 21 | let sevenPhrasesCategory = Category(id: "third_category", "Category 3", phrases: PaginationBaseTest.phrases(count: 7)) 22 | let twoPhrasesCategory = Category(id: "fourth_category", "Category 4", phrases: PaginationBaseTest.phrases(count: 2)) 23 | 24 | override func setUp() { 25 | let app = XCUIApplication() 26 | app.configure { 27 | Arguments(.resetAppDataOnLaunch, .enableListeningMode, .disableAnimations) 28 | Environment(.overridePresets) { 29 | Presets { 30 | eightPhrasesCategory 31 | ninePhrasesCategory 32 | sevenPhrasesCategory 33 | twoPhrasesCategory 34 | } 35 | } 36 | } 37 | app.launch() 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /Vocable/Features/Keyboard/Keypad/Layout/Components/KeyboardLayoutGroup.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardLayoutGroup.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 7/31/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct KeyboardLayoutGroup: KeyboardLayoutElement { 12 | 13 | var children: [any KeyboardLayoutElement] 14 | 15 | init(children: [any KeyboardLayoutElement]) { 16 | self.children = children 17 | } 18 | 19 | init(@KeyboardLayoutContent builder: () -> [any KeyboardLayoutElement]) { 20 | self.init(children: builder()) 21 | } 22 | 23 | func makeKeys(environment: KeyboardLayoutEnvironment) -> [KeyboardLayoutKey] { 24 | let indexed = children.indexed() 25 | return indexed.flatMap { (index, key) -> [KeyboardLayoutKey] in 26 | let isFirst = (index == indexed.startIndex) 27 | let isLast = (index == indexed.endIndex) 28 | return if isFirst && isLast { 29 | key.makeKeys(environment: environment) 30 | } else if isFirst { 31 | key.environment(\.padding.trailing, .zero) 32 | .makeKeys(environment: environment) 33 | } else if isLast { 34 | key.environment(\.padding.leading, .zero) 35 | .makeKeys(environment: environment) 36 | } else { 37 | key.environment(\.padding, .zero) 38 | .makeKeys(environment: environment) 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Tests/VocableUITests/Tests/PresetsOverrideTestCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PresetsOverrideTestCase.swift 3 | // VocableUITests 4 | // 5 | // Created by Chris Stroud on 4/22/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | class PresetsOverrideTestCase: XCTestCase { 12 | 13 | override class func setUp() { 14 | super.setUp() 15 | 16 | let app = XCUIApplication() 17 | app.configure { 18 | Arguments(.resetAppDataOnLaunch, .enableListeningMode, .disableAnimations) 19 | Environment(.overridePresets) { 20 | Presets { 21 | Category("Custom Basic Needs") { 22 | Phrase("Test Phrase") 23 | Phrase("Another Phrase") 24 | Phrase(id: "specific_id", "With some text") 25 | Phrase(id: "another_id", languageCode: "es", "No sé donde estoy") 26 | } 27 | Category("Another Category") { 28 | // Empty 29 | } 30 | Category(id: "custom-identifier", "Category Name Here") { 31 | // Also Empty 32 | } 33 | } 34 | } 35 | } 36 | app.launch() 37 | } 38 | 39 | func testCustomPresets() throws { 40 | 41 | // Use those custom presets! 42 | 43 | // Uncomment if you want to play with the categories on the simulator 44 | // Thread.sleep(until: .distantFuture) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Tests/VocableUITests/Tests/BaseTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseScreen.swift 3 | // VocableUITests 4 | // 5 | // Created by Kevin Stechler on 4/27/20. 6 | // Copyright © 2020 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import XCTest 11 | 12 | let app = XCUIApplication() 13 | 14 | class BaseTest: XCTestCase { 15 | 16 | override func setUpWithError() throws { 17 | 18 | app.configure { 19 | Arguments(.resetAppDataOnLaunch, .enableListeningMode, .disableAnimations) 20 | } 21 | continueAfterFailure = false 22 | app.launch() 23 | 24 | addUIInterruptionMonitor(withDescription: "SpeechRecognition") { (alert) -> Bool in 25 | alert.buttons["OK"].tap() 26 | return true 27 | } 28 | 29 | // Ensure the main screen has loaded before continuing 30 | try MainScreen.outputText.assertExistence(timeout: 2.0, "Did not arrive on main screen") 31 | } 32 | 33 | override func tearDown() { 34 | super.tearDown() 35 | captureFailure(name: self.name) 36 | } 37 | 38 | func captureFailure(name: String) { 39 | let screenshot = XCUIScreen.main.screenshot() 40 | let attachment = XCTAttachment(screenshot: screenshot) 41 | attachment.name = name 42 | attachment.lifetime = .deleteOnSuccess 43 | add(attachment) 44 | } 45 | 46 | func randomString(length: Int) -> String { 47 | let letters = "abcdefghijklmnopqrstuvwxyz" 48 | return String((0..(_ keyPath: WritableKeyPath, _ value: T) -> some KeyboardLayoutElement { 13 | KeyboardLayoutEnvironmentModifier(content: self) { env in 14 | env[keyPath: keyPath] = value 15 | } 16 | } 17 | 18 | func mutatingEnvironment(_ t: @escaping (inout KeyboardLayoutEnvironment) -> Void) -> some KeyboardLayoutElement { 19 | KeyboardLayoutEnvironmentModifier(content: self, transform: t) 20 | } 21 | } 22 | 23 | private struct KeyboardLayoutEnvironmentModifier: KeyboardLayoutElement { 24 | private(set) var content: Content 25 | let transform: (inout KeyboardLayoutEnvironment) -> Void 26 | 27 | func makeKeys(environment: KeyboardLayoutEnvironment) -> [KeyboardLayoutKey] { 28 | var newEnvironment = environment 29 | transform(&newEnvironment) 30 | let merged = newEnvironment.merging(ancestor: environment) 31 | return content.makeKeys(environment: merged) 32 | } 33 | 34 | var children: [any KeyboardLayoutElement] { 35 | get { 36 | [content] 37 | } 38 | set { 39 | if let converted = newValue.first as? Content { 40 | content = converted 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Vocable/Features/Keyboard/Keypad/Layout/Components/KeyboardLayoutRelativeWidth/KeyboardLayoutRelativeWidthProportional.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardLayoutRelativeWidthProportional.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 7/31/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct KeyboardLayoutRelativeWidthProportional: KeyboardLayoutRelativeWidth { 12 | 13 | let multiplier: CGFloat 14 | 15 | init(multiplier: CGFloat) { 16 | self.multiplier = multiplier 17 | } 18 | 19 | func value( 20 | in containerWidth: CGFloat, 21 | environment: KeyboardLayoutEnvironment 22 | ) -> CGFloat { 23 | max(containerWidth * multiplier, .zero) * signMultiplier() 24 | } 25 | 26 | private var sign: FloatingPointSign = .plus 27 | 28 | private func signMultiplier() -> CGFloat { 29 | switch sign { 30 | case .plus: 1.0 31 | case .minus: -1.0 32 | } 33 | } 34 | 35 | static prefix func - (value: Self) -> Self { 36 | var value = value 37 | switch value.sign { 38 | case .plus: 39 | value.sign = .minus 40 | case .minus: 41 | value.sign = .plus 42 | } 43 | return value 44 | } 45 | } 46 | 47 | extension KeyboardLayoutRelativeWidth where Self == KeyboardLayoutRelativeWidthProportional { 48 | 49 | static var zero: Self { 50 | KeyboardLayoutRelativeWidthProportional(multiplier: .zero) 51 | } 52 | 53 | static func proportional(_ multiplier: CGFloat) -> Self { 54 | KeyboardLayoutRelativeWidthProportional(multiplier: multiplier) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Vocable/Common/AppStorage+Init.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppStorage+Init.swift 3 | // Vocable 4 | // 5 | // Created by Jesse Morgan on 4/21/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | 12 | extension AppStorage { 13 | 14 | init(wrappedValue: Value, _ key: UserDefaultsKey, store: UserDefaults? = nil) where Value == String { 15 | self.init(wrappedValue: wrappedValue, key.value, store: store) 16 | } 17 | 18 | init(wrappedValue: Value, _ key: UserDefaultsKey, store: UserDefaults? = nil) where Value == Bool { 19 | self.init(wrappedValue: wrappedValue, key.value, store: store) 20 | } 21 | 22 | init(wrappedValue: Value, _ key: UserDefaultsKey, store: UserDefaults? = nil) where Value == Int { 23 | self.init(wrappedValue: wrappedValue, key.value, store: store) 24 | } 25 | 26 | init(wrappedValue: Value, _ key: UserDefaultsKey, store: UserDefaults? = nil) where Value == Double { 27 | self.init(wrappedValue: wrappedValue, key.value, store: store) 28 | } 29 | 30 | init(wrappedValue: Value, _ key: UserDefaultsKey, store: UserDefaults? = nil) where Value == URL { 31 | self.init(wrappedValue: wrappedValue, key.value, store: store) 32 | } 33 | 34 | init(wrappedValue: Value, _ key: UserDefaultsKey, store: UserDefaults? = nil) where Value == Data { 35 | self.init(wrappedValue: wrappedValue, key.value, store: store) 36 | } 37 | 38 | init(wrappedValue: Value, _ key: UserDefaultsKey, store: UserDefaults? = nil) where Value: RawRepresentable, Value.RawValue == Int { 39 | self.init(wrappedValue: wrappedValue, key.value, store: store) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Vocable/HeadTracking/Interpolation/PulseController/PIDControlConstants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PulseConstants.swift 3 | // Pulse 4 | // 5 | // Created by Dawid Cieslak on 15/04/2018. 6 | // Copyright © 2018 Dawid Cieslak. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// Configuration for tuning 12 | enum PulseConstants: SingleControlDisplayable { 13 | 14 | case minimumValueStep 15 | case proportionalGain 16 | case integralGain 17 | case derivativeGain 18 | 19 | /// Name of control 20 | public var name: String { 21 | switch self { 22 | case .minimumValueStep: 23 | return "Minimum Value Step" 24 | case .proportionalGain: 25 | return "Proportional Gain" 26 | case .integralGain: 27 | return "Integral Gain" 28 | case .derivativeGain: 29 | return "Derivative Gain" 30 | } 31 | } 32 | 33 | /// Minimum value that can be set 34 | public var minimumValue: Float { 35 | switch self { 36 | case .minimumValueStep: 37 | return 0.003 38 | case .proportionalGain: 39 | return 1 40 | case .integralGain: 41 | return 0.1 42 | case .derivativeGain: 43 | return 0.1 44 | } 45 | } 46 | 47 | /// Maximum value that can be set 48 | public var maximumValue: Float { 49 | switch self { 50 | case .minimumValueStep: 51 | return 0.3 52 | case .proportionalGain: 53 | return 5 54 | case .integralGain: 55 | return 1 56 | case .derivativeGain: 57 | return 1 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Vocable/Features/Keyboard/Keypad/Layout/Components/KeyboardLayoutRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardLayoutRow.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 7/31/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct KeyboardLayoutRow: KeyboardLayoutElement { 12 | 13 | private(set) var index: Int = .zero 14 | var children: [any KeyboardLayoutElement] 15 | 16 | let debugID: String 17 | 18 | init( 19 | debugID: String = "", 20 | @KeyboardLayoutContent builder: () -> [any KeyboardLayoutElement] 21 | ) { 22 | self.debugID = debugID 23 | self.children = builder() 24 | } 25 | 26 | func makeKeys(environment: KeyboardLayoutEnvironment) -> [KeyboardLayoutKey] { 27 | 28 | var childEnvironment = environment 29 | childEnvironment.padding = .zero 30 | 31 | return children 32 | .indexed() 33 | .flatMap { (keyIndex, child) in 34 | child.mutatingEnvironment { env in 35 | env.rowIndex = index 36 | if keyIndex == children.indices.first { 37 | env.padding.leading += environment.padding.leading 38 | } 39 | if keyIndex == children.indices.last { 40 | env.padding.trailing += environment.padding.trailing 41 | } 42 | } 43 | .makeKeys(environment: childEnvironment) 44 | } 45 | } 46 | 47 | func withIndex(_ index: Int) -> KeyboardLayoutRow { 48 | var result = self 49 | result.index = index 50 | return result 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Vocable/Common/Views/GazeableAlertController/GazeableAlertPresentationController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GazeableAlertPresentationController.swift 3 | // Vocable ACC 4 | // 5 | // Created by Steve Foster on 3/23/20. 6 | // Copyright © 2020 WillowTree. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | final class GazeableAlertPresentationController: UIPresentationController { 12 | 13 | private lazy var dimmedBackgroundView: GazeEatingView = { 14 | let view = GazeEatingView() 15 | view.backgroundColor = .black 16 | view.alpha = 0 17 | return view 18 | }() 19 | 20 | override var frameOfPresentedViewInContainerView: CGRect { 21 | return CGRect(origin: .zero, size: containerView?.bounds.size ?? .zero) 22 | } 23 | 24 | override func dismissalTransitionWillBegin() { 25 | presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in 26 | self.dimmedBackgroundView.alpha = 0 27 | }, completion: nil) 28 | } 29 | 30 | override func presentationTransitionWillBegin() { 31 | dimmedBackgroundView.alpha = 0 32 | containerView?.insertSubview(dimmedBackgroundView, at: 0) 33 | 34 | presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in 35 | self.dimmedBackgroundView.alpha = 0.4 36 | }, completion: nil) 37 | } 38 | 39 | override func containerViewWillLayoutSubviews() { 40 | super.containerViewWillLayoutSubviews() 41 | 42 | guard let containerView = containerView else { return } 43 | 44 | presentedView?.frame = frameOfPresentedViewInContainerView 45 | dimmedBackgroundView.frame = containerView.bounds 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /Vocable/Features/Keyboard/Keypad/Layout/Components/KeyboardLayoutKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardLayoutKey.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 7/31/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct KeyboardLayoutKey: Hashable, KeyboardLayoutElement { 12 | 13 | typealias Action = KeyboardKeyAction 14 | 15 | private(set) var environment: KeyboardLayoutEnvironment = .init() 16 | 17 | var children: [any KeyboardLayoutElement] { 18 | get { 19 | [] 20 | } 21 | // swiftlint:disable:next unused_setter_value 22 | set { 23 | // no-op 24 | } 25 | } 26 | 27 | func makeKeys(environment: KeyboardLayoutEnvironment) -> [KeyboardLayoutKey] { 28 | var result = self 29 | result.environment = environment 30 | return [result] 31 | } 32 | 33 | private(set) var action: Action 34 | 35 | init(_ value: Character) { 36 | self.init(.insertCharacter(value)) 37 | } 38 | 39 | init(_ action: Action) { 40 | self.action = action 41 | } 42 | 43 | func withModifier(modifier: Character?) -> KeyboardLayoutKey { 44 | guard let modifier else { 45 | return self 46 | } 47 | var result = self 48 | if case .insertCharacter(let character) = result.action { 49 | var unicodeScalars = character.unicodeScalars 50 | unicodeScalars.append(contentsOf: modifier.unicodeScalars) 51 | if let newValue = String(unicodeScalars).first { 52 | result.action = .insertCharacter(newValue) 53 | } 54 | } 55 | return result 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Vocable/Features/Keyboard/Keypad/UI/KeyboardKeyContainerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardKeyContainerView.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 7/31/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | extension KeyboardView { 13 | 14 | final class KeyContainerView: UIView { 15 | let button: KeyButton 16 | 17 | lazy private(set) var buttonWidthConstraint: NSLayoutConstraint = { 18 | button.widthAnchor.constraint(equalToConstant: 10.0).withPriority(999) 19 | }() 20 | 21 | required init?(coder: NSCoder) { 22 | fatalError("init(coder:) has not been implemented") 23 | } 24 | 25 | init(button: KeyButton) { 26 | self.button = button 27 | super.init(frame: .zero) 28 | 29 | button.translatesAutoresizingMaskIntoConstraints = false 30 | addSubview(button) 31 | NSLayoutConstraint.activate( 32 | [ 33 | button.topAnchor.constraint( 34 | equalTo: self.topAnchor 35 | ), 36 | button.leadingAnchor.constraint( 37 | equalTo: self.layoutMarginsGuide.leadingAnchor 38 | ), 39 | button.bottomAnchor.constraint( 40 | equalTo: self.bottomAnchor 41 | ), 42 | button.trailingAnchor.constraint( 43 | equalTo: self.layoutMarginsGuide.trailingAnchor 44 | ), 45 | buttonWidthConstraint 46 | ] 47 | ) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Vocable/Common/AddPhraseCollectionViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AddPhraseCollectionViewCell.swift 3 | // Vocable 4 | // 5 | // Created by Jesse Morgan on 4/8/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class AddPhraseCollectionViewCell: PresetItemCollectionViewCell { 12 | 13 | private let borderWidth = 6.0 14 | private let cornerRadius = 8.0 15 | 16 | override init(frame: CGRect) { 17 | super.init(frame: frame) 18 | commonInit() 19 | } 20 | 21 | required init?(coder: NSCoder) { 22 | super.init(coder: coder) 23 | commonInit() 24 | } 25 | 26 | private func commonInit() { 27 | contentView.preservesSuperviewLayoutMargins = true 28 | 29 | fillColor = .collectionViewBackgroundColor 30 | 31 | textLabel.numberOfLines = 1 32 | setup(title: String(localized: "preset.category.add.phrase.title"), 33 | with: UIImage(systemName: "plus")) 34 | } 35 | 36 | override func updateContent() { 37 | super.updateContent() 38 | 39 | if isHighlighted && !isSelected { 40 | borderedView.borderColor = .cellBorderHighlightColor 41 | } else if isSelected { 42 | borderedView.borderColor = .cellSelectionColor 43 | } else { 44 | borderedView.borderColor = .categoryBackgroundColor 45 | } 46 | 47 | borderedView.backgroundColor = .collectionViewBackgroundColor 48 | borderedView.borderWidth = isSelected ? 0 : borderWidth 49 | borderedView.isOpaque = true 50 | borderedView.cornerRadius = cornerRadius 51 | borderedView.borderDashPattern = [6, 6] 52 | 53 | layoutMargins = .init(uniform: cornerRadius + borderWidth) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Vocable/Features/Voice/ListeningFeedbackSuccessView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListeningFeedbackSuccessView.swift 3 | // Vocable 4 | // 5 | // Created by Jesse Morgan on 4/18/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | final class ListeningFeedbackSuccessView: UIView { 12 | 13 | // MARK: Properties 14 | 15 | private let label = UILabel() 16 | 17 | // MARK: Initializers 18 | 19 | init() { 20 | super.init(frame: .zero) 21 | commonInit() 22 | } 23 | 24 | required init?(coder: NSCoder) { 25 | super.init(coder: coder) 26 | commonInit() 27 | } 28 | 29 | private func commonInit() { 30 | let font: UIFont = sizeClass == .hRegular_vRegular 31 | ? .systemFont(ofSize: 34, weight: .bold) 32 | : .systemFont(ofSize: 22, weight: .bold) 33 | let symbol = UIImage(systemName: "checkmark.circle.fill")!.withTintColor(.cellSelectionColor) 34 | 35 | let text = String(localized: "listening_mode.feedback.confirmation.title") 36 | let attributes: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: UIColor.defaultTextColor] 37 | label.attributedText = NSAttributedString.imageAttachedString(for: text, with: symbol, attributes: attributes) 38 | 39 | addSubview(label) 40 | label.translatesAutoresizingMaskIntoConstraints = false 41 | NSLayoutConstraint.activate([label.centerXAnchor.constraint(equalTo: layoutMarginsGuide.centerXAnchor), 42 | label.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), 43 | label.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor)]) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Vocable/HeadTracking/Interpolation/PulseController/Queue.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Queue.swift 3 | // Pulse 4 | // 5 | // Created by Dawid Cieslak on 16/04/2018. 6 | // Copyright © 2018 Dawid Cieslak. All rights reserved. 7 | // 8 | import UIKit 9 | 10 | /// Provides FILO queue in fixed size 11 | /// 12 | /// This is "ring" type of queue - older values will be overwritten with new ones 13 | struct Queue { 14 | 15 | /// Current index of cell to store new value 16 | private var writeHeadIndex: Int = 0 17 | 18 | /// Stores all elements 19 | private var array: [T] 20 | 21 | /// Init with queue configuration 22 | /// 23 | /// - Parameters: 24 | /// - count: Maximum count of elements 25 | /// - initialValue: Initial value for all elements 26 | init(count: Int, initialValue: T) { 27 | array = [T](repeating: initialValue, count: count) 28 | } 29 | 30 | /// Adds given value to queue 31 | /// 32 | /// - Parameter value: Value to be added 33 | mutating func append(value: T) { 34 | array[writeHeadIndex] = value 35 | 36 | // Move pointer 37 | writeHeadIndex = (writeHeadIndex + 1) % array.count 38 | } 39 | 40 | /// Retrives of all elements in queue and returns in revered order (oldest element is firest) 41 | /// 42 | /// - Returns: Array of all elements in reversed order 43 | func allValuesReversed() -> [T] { 44 | var allValues: [T] = [T]() 45 | 46 | for i in 1...array.count { 47 | let readIndex = (writeHeadIndex - i + array.count) % array.count 48 | let currentValue = array[readIndex] 49 | allValues.append(currentValue) 50 | } 51 | 52 | return allValues 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /Tests/VocableUITests/Tests/CategoryIdentifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CategoryIdentifiers.swift 3 | // Vocable 4 | // 5 | // Created by Rudy Salas and Canan Arikan on 3/18/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct CategoryIdentifier { 12 | 13 | let identifier: String 14 | init(_ identifier: String) { 15 | self.identifier = identifier 16 | } 17 | 18 | static let mySayings = CategoryIdentifier("preset_user_favorites") 19 | static let recents = CategoryIdentifier("preset_user_recents") 20 | static let listen = CategoryIdentifier("preset_listening_mode") 21 | static let keyPad = CategoryIdentifier("preset_user_keypad") 22 | static let general = CategoryIdentifier("preset_C0B2A1A8-8333-4121-B4A8-FFFA185EB5D2") 23 | static let basicNeeds = CategoryIdentifier("preset_F8F5D1C9-0AA6-4152-BF7C-0851ACD1406B") 24 | static let personalCare = CategoryIdentifier("preset_E7ADBE88-2722-4DE7-BDC1-994F07EA294B") 25 | static let conversation = CategoryIdentifier("preset_EB7A9732-E28E-4440-A88B-BA2A1ACFBD76") 26 | static let environment = CategoryIdentifier("preset_52CA4E71-4A8C-4EA8-8EA8-C4B18AA16EC8") 27 | 28 | } 29 | 30 | struct PresetCategories { 31 | 32 | var list: [CategoryIdentifier] 33 | 34 | init() { 35 | self.list = [ 36 | CategoryIdentifier.general, 37 | CategoryIdentifier.basicNeeds, 38 | CategoryIdentifier.personalCare, 39 | CategoryIdentifier.conversation, 40 | CategoryIdentifier.environment, 41 | CategoryIdentifier.keyPad, 42 | CategoryIdentifier.mySayings, 43 | CategoryIdentifier.recents, 44 | CategoryIdentifier.listen 45 | ] 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /Vocable/Extensions/UICollectionDiffableDatasource+Mutations.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UICollectionDiffableDatasource+Mutations.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 8/21/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | /// A handful of convenience mutation functions to apply changes 13 | /// to the current snapshot with or without animation 14 | extension UICollectionViewDiffableDataSource { 15 | 16 | func reloadItem(_ item: ItemIdentifierType, animated: Bool = true) { 17 | reloadItems([item], animated: animated) 18 | } 19 | 20 | func reloadItems(_ items: [ItemIdentifierType], animated: Bool = true) { 21 | var snapshot = snapshot() 22 | snapshot.reloadItems(items) 23 | apply(snapshot, animatingDifferences: animated) 24 | } 25 | 26 | func appendItem( 27 | _ item: ItemIdentifierType, 28 | in section: SectionIdentifierType, 29 | animated: Bool = true 30 | ) { 31 | appendItems([item], in: section, animated: animated) 32 | } 33 | 34 | func appendItems( 35 | _ items: [ItemIdentifierType], 36 | in section: SectionIdentifierType, 37 | animated: Bool = true 38 | ) { 39 | var snapshot = snapshot() 40 | snapshot.appendItems(items, toSection: section) 41 | apply(snapshot, animatingDifferences: animated) 42 | } 43 | 44 | func removeItem(_ item: ItemIdentifierType, animated: Bool = true) { 45 | removeItems([item], animated: animated) 46 | } 47 | 48 | func removeItems(_ items: [ItemIdentifierType], animated: Bool = true) { 49 | var snapshot = snapshot() 50 | snapshot.deleteItems(items) 51 | apply(snapshot, animatingDifferences: animated) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Vocable/Features/Keyboard/Keypad/Layout/Components/KeyboardLayoutDirectionalInsets.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardLayoutDirectionalInsets.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 7/31/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct KeyboardLayoutDirectionalInsets: Hashable { 12 | 13 | var leading: any KeyboardLayoutRelativeWidth 14 | var trailing: any KeyboardLayoutRelativeWidth 15 | 16 | static var zero: KeyboardLayoutDirectionalInsets { 17 | KeyboardLayoutDirectionalInsets(leading: .zero, trailing: .zero) 18 | } 19 | 20 | init(leading: some KeyboardLayoutRelativeWidth) { 21 | self.leading = AnyKeyboardLayoutRelativeWidth(leading) 22 | self.trailing = AnyKeyboardLayoutRelativeWidth(.zero) 23 | } 24 | 25 | init(trailing: some KeyboardLayoutRelativeWidth) { 26 | self.leading = AnyKeyboardLayoutRelativeWidth(.zero) 27 | self.trailing = AnyKeyboardLayoutRelativeWidth(trailing) 28 | } 29 | 30 | init(leading: some KeyboardLayoutRelativeWidth, trailing: some KeyboardLayoutRelativeWidth) { 31 | self.leading = AnyKeyboardLayoutRelativeWidth(leading) 32 | self.trailing = AnyKeyboardLayoutRelativeWidth(trailing) 33 | } 34 | 35 | func hash(into hasher: inout Hasher) { 36 | hasher.combine(AnyKeyboardLayoutRelativeWidth(leading)) 37 | hasher.combine(AnyKeyboardLayoutRelativeWidth(trailing)) 38 | } 39 | 40 | static func == (lhs: KeyboardLayoutDirectionalInsets, rhs: KeyboardLayoutDirectionalInsets) -> Bool { 41 | AnyKeyboardLayoutRelativeWidth(lhs.leading) == AnyKeyboardLayoutRelativeWidth(rhs.leading) && 42 | AnyKeyboardLayoutRelativeWidth(lhs.trailing) == AnyKeyboardLayoutRelativeWidth(rhs.trailing) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Mac 6 | *.DS_Store 7 | 8 | ## Build generated 9 | build/ 10 | DerivedData/ 11 | 12 | ## Various settings 13 | *.pbxuser 14 | !default.pbxuser 15 | *.mode1v3 16 | !default.mode1v3 17 | *.mode2v3 18 | !default.mode2v3 19 | *.perspectivev3 20 | !default.perspectivev3 21 | xcuserdata/ 22 | 23 | ## Other 24 | *.moved-aside 25 | *.xccheckout 26 | *.xcscmblueprint 27 | 28 | ## Obj-C/Swift specific 29 | *.hmap 30 | *.ipa 31 | *.dSYM.zip 32 | *.dSYM 33 | 34 | ## Playgrounds 35 | timeline.xctimeline 36 | playground.xcworkspace 37 | 38 | # Swift Package Manager 39 | # 40 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 41 | # Packages/ 42 | # Package.pins 43 | # Package.resolved 44 | .build/ 45 | 46 | # CocoaPods 47 | # 48 | # We recommend against adding the Pods directory to your .gitignore. However 49 | # you should judge for yourself, the pros and cons are mentioned at: 50 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 51 | # 52 | # Pods/ 53 | 54 | # Carthage 55 | # 56 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 57 | # Carthage/Checkouts 58 | 59 | Carthage/Checkouts 60 | !Carthage/Build 61 | 62 | # fastlane 63 | # 64 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 65 | # screenshots whenever they are needed. 66 | # For more information about the recommended setup visit: 67 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 68 | 69 | fastlane/report.xml 70 | fastlane/Preview.html 71 | fastlane/screenshots/**/*.png 72 | fastlane/test_output 73 | -------------------------------------------------------------------------------- /Tests/VocableUITests/Screens/KeyboardScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardScreen.swift 3 | // VocableUITests 4 | // 5 | // Created by Kevin Stechler on 4/24/20. 6 | // Copyright © 2020 WillowTree. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | class KeyboardScreen: BaseScreen { 12 | private static let app = XCUIApplication() 13 | 14 | static let keyboardView = XCUIApplication().otherElements[.shared.keyboard.view] 15 | static let keyboardTextView = XCUIApplication().textViews[.shared.keyboard.outputTextView] 16 | static let favoriteButton = XCUIApplication().buttons[.shared.keyboard.favoriteButton] 17 | static let checkmarkAddButton = XCUIApplication().buttons[.shared.keyboard.saveButton] 18 | static let createDuplicateButton = XCUIApplication().buttons[.shared.alert.createDuplicateButton] 19 | 20 | static func typeText( 21 | _ textToType: String, 22 | file: StaticString = #file, 23 | line: UInt = #line 24 | ) throws { 25 | // Ensure the keyboard is visible on screen before tapping any cells 26 | try keyboardView.assertExistence( 27 | timeout: 0.5, 28 | "Failed to locate keyboard", 29 | file: file, 30 | line: line 31 | ) 32 | 33 | // The entire keyboard is visible by design, so it is okay to 34 | // not wait for the existence of each cell before tapping. It's 35 | // a minor optimization, but the savings add up. 36 | for char in textToType.uppercased() { 37 | keyboardView.buttons[.shared.keyboard.key(.insertCharacter(char))].tap() 38 | } 39 | } 40 | 41 | static func randomString(length: Int) -> String { 42 | let letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" 43 | return String((0.. [any KeyboardLayoutElement] { 15 | components 16 | } 17 | 18 | static func buildExpression(_ expression: any KeyboardLayoutElement) -> [any KeyboardLayoutElement] { 19 | [expression] 20 | } 21 | 22 | static func buildExpression(_ expression: [any KeyboardLayoutElement]) -> [any KeyboardLayoutElement] { 23 | expression 24 | } 25 | 26 | static func buildEither(first component: [any KeyboardLayoutElement]) -> [any KeyboardLayoutElement] { 27 | component 28 | } 29 | 30 | static func buildEither(second component: [any KeyboardLayoutElement]) -> [any KeyboardLayoutElement] { 31 | component 32 | } 33 | 34 | static func buildArray(_ components: [[any KeyboardLayoutElement]]) -> [any KeyboardLayoutElement] { 35 | Array(components.joined()) 36 | } 37 | 38 | static func buildOptional(_ component: [any KeyboardLayoutElement]?) -> [any KeyboardLayoutElement] { 39 | component ?? [] 40 | } 41 | 42 | static func buildFinalResult(_ component: [any KeyboardLayoutElement]) -> [any KeyboardLayoutElement] { 43 | component 44 | } 45 | 46 | static func buildPartialBlock(first: [any KeyboardLayoutElement]) -> [any KeyboardLayoutElement] { 47 | first 48 | } 49 | 50 | static func buildPartialBlock( 51 | accumulated: [any KeyboardLayoutElement], 52 | next: [any KeyboardLayoutElement] 53 | ) -> [any KeyboardLayoutElement] { 54 | accumulated + next 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Vocable/CoreData/Phrases.xcdatamodeld/Phrases.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /Vocable/HeadTracking/ToastWindow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotificationWindow.swift 3 | // Vocable 4 | // 5 | // Created by Thomas Shealy on 3/25/20. 6 | // Copyright © 2020 WillowTree. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import ARKit 11 | import Combine 12 | 13 | class ToastWindow: UIWindow { 14 | 15 | private static var _shared: ToastWindow? 16 | 17 | static var shared: ToastWindow { 18 | if _shared == nil { 19 | let shared = ToastWindow(frame: UIScreen.main.bounds) 20 | shared.backgroundColor = .clear 21 | shared.translatesAutoresizingMaskIntoConstraints = false 22 | shared.rootViewController = ToastContainerViewController() 23 | _shared = shared 24 | } 25 | return _shared! 26 | } 27 | 28 | var toastContainerViewController: ToastContainerViewController { 29 | return self.rootViewController as! ToastContainerViewController 30 | } 31 | 32 | override init(frame: CGRect) { 33 | super.init(frame: frame) 34 | commonInit() 35 | 36 | } 37 | 38 | override init(windowScene: UIWindowScene) { 39 | super.init(windowScene: windowScene) 40 | commonInit() 41 | } 42 | 43 | required init?(coder: NSCoder) { 44 | super.init(coder: coder) 45 | commonInit() 46 | } 47 | 48 | private func commonInit() { 49 | self.isUserInteractionEnabled = false 50 | } 51 | 52 | func presentPersistentWarning(with title: String) { 53 | toastContainerViewController.handleWarning(with: title, shouldDisplay: true) 54 | } 55 | 56 | func dismissPersistentWarning() { 57 | toastContainerViewController.handleWarning(with: nil, shouldDisplay: false) 58 | } 59 | 60 | func presentEphemeralToast(withTitle: String) { 61 | toastContainerViewController.handlePhraseSaved(toastLabelText: withTitle) 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /Vocable/Features/Keyboard/Keypad/Layout/Components/KeyboardLayoutRelativeWidth/KeyboardLayoutRelativeWidthCompound.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardLayoutRelativeWidthCompound.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 7/31/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct KeyboardLayoutRelativeWidthCompound: KeyboardLayoutRelativeWidth { 12 | 13 | private let lhs: AnyKeyboardLayoutRelativeWidth 14 | private let rhs: AnyKeyboardLayoutRelativeWidth 15 | private var sign: FloatingPointSign = .plus 16 | 17 | init(lhs: any KeyboardLayoutRelativeWidth, rhs: any KeyboardLayoutRelativeWidth) { 18 | self.lhs = AnyKeyboardLayoutRelativeWidth(lhs) 19 | self.rhs = AnyKeyboardLayoutRelativeWidth(rhs) 20 | } 21 | 22 | func value(in containerWidth: CGFloat, environment: KeyboardLayoutEnvironment) -> CGFloat { 23 | let lhs_value = lhs.value(in: containerWidth, environment: environment) 24 | let rhs_value = rhs.value(in: containerWidth, environment: environment) 25 | return (lhs_value + rhs_value) * signMultiplier() 26 | } 27 | 28 | private func signMultiplier() -> CGFloat { 29 | switch sign { 30 | case .plus: 1.0 31 | case .minus: -1.0 32 | } 33 | } 34 | 35 | static prefix func - (value: KeyboardLayoutRelativeWidthCompound) -> KeyboardLayoutRelativeWidthCompound { 36 | var value = value 37 | switch value.sign { 38 | case .plus: 39 | value.sign = .minus 40 | case .minus: 41 | value.sign = .plus 42 | } 43 | return value 44 | } 45 | } 46 | 47 | extension KeyboardLayoutRelativeWidth where Self == KeyboardLayoutRelativeWidthCompound { 48 | static func compound( 49 | _ lhs: any KeyboardLayoutRelativeWidth, 50 | _ rhs: any KeyboardLayoutRelativeWidth 51 | ) -> Self { 52 | KeyboardLayoutRelativeWidthCompound( 53 | lhs: lhs, 54 | rhs: rhs 55 | ) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Vocable/Extensions/UICollectionView+Helpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UICollectionView+Helpers.swift 3 | // Vocable 4 | // 5 | // Created by Jesse Morgan on 4/20/20. 6 | // Copyright © 2020 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | extension UICollectionView { 13 | func indexPath(containing view: UIView) -> IndexPath? { 14 | for cell in self.visibleCells where view.isDescendant(of: cell) { 15 | if let indexPath = self.indexPath(for: cell) { 16 | return indexPath 17 | } 18 | } 19 | return nil 20 | } 21 | 22 | func indexPath(nearestTo indexPath: IndexPath) -> IndexPath? { 23 | let validSections = 0.. IndexPath? { 35 | let validSections = 0..= itemsInSection { 41 | return nil 42 | } else { 43 | return IndexPath(row: candidateIndex, section: indexPath.section) 44 | } 45 | } 46 | 47 | func indexPath(before indexPath: IndexPath) -> IndexPath? { 48 | let validSections = 0.. Void) 22 | 23 | // Called when developer changes `PID` configuration 24 | var configurationChanged: ((TunningViewController, Pulse.Configuration) -> Void) 25 | 26 | override func loadView() { 27 | view = tunningView 28 | } 29 | 30 | init(pulse: Pulse, configuration: TunningView.Configuration, closeClosure: @escaping ((TunningViewController) -> Void), configurationChanged: @escaping ((TunningViewController, Pulse.Configuration) -> Void)) { 31 | self.closeClosure = closeClosure 32 | self.configurationChanged = configurationChanged 33 | self.pulse = pulse 34 | 35 | super.init(nibName: nil, bundle: nil) 36 | 37 | self.tunningView = TunningView(isHorizontal: pulse.isHorizontal, configuration: configuration, closeClosure: { [weak self] _ in 38 | guard let self = self else { return } 39 | self.closeClosure(self) 40 | }, configurationChanged: { [weak self] (_, configuration) in 41 | guard let self = self else { return } 42 | self.configurationChanged(self, configuration) 43 | }) 44 | } 45 | 46 | required init?(coder aDecoder: NSCoder) { 47 | fatalError("init(coder:) has not been implemented") 48 | } 49 | 50 | func show() { 51 | tunningView?.layoutIfNeeded() 52 | tunningView?.visibilityState = .fullyVisible 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /.github/workflows/crowdin_pull.yml: -------------------------------------------------------------------------------- 1 | name: Pull Translations From Crowdin 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: "0 */3 * * *" # Run every 3 hours 6 | 7 | jobs: 8 | synchronize-with-crowdin: 9 | runs-on: macos-14 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v4 13 | 14 | - name: Install Crowdin CLI 15 | run: brew install crowdin 16 | shell: sh 17 | 18 | - name: Download Translations 19 | run: crowdin bundle download 6 20 | shell: sh 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.CROWDIN_GITHUB_TOKEN }} 23 | CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} 24 | CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} 25 | 26 | - name: Set up Ruby environment 27 | uses: ruby/setup-ruby@v1 28 | with: 29 | ruby-version: 3.0 30 | 31 | - name: Install bundler 32 | run: gem install bundler 33 | 34 | - name: Install dependencies 35 | run: bundle install 36 | 37 | - name: Install SwiftLint 38 | run: brew install swiftlint 39 | 40 | - name: Import XLIFFs to project 41 | uses: ./.github/workflows/actions/run_lane 42 | with: 43 | lane: xliff_import 44 | 45 | - name: Create Pull Request 46 | uses: peter-evans/create-pull-request@v6 47 | with: 48 | token: ${{ secrets.CROWDIN_PR_BOT_TOKEN }} 49 | commit-message: Update Localizations 50 | committer: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 51 | author: ${{ github.actor }} <${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com> 52 | signoff: false 53 | branch: crowdin/update-translations-bot 54 | base: develop 55 | delete-branch: true 56 | add-paths: | 57 | Vocable/**/*.xcstrings 58 | Vocable/**/*.strings 59 | title: '[CI] Update Localizations' 60 | body: | 61 | Automated localization update from Crowdin. 62 | labels: | 63 | localization 64 | automated 65 | draft: false -------------------------------------------------------------------------------- /Vocable/Supporting Files/AXIdentifier/AccessibilityID+Shared+Keyboard.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccessibilityID+Shared+Keyboard.swift 3 | // Vocable 4 | // 5 | // Created by Rudy Salas on 5/18/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension AccessibilityID.shared { 12 | public struct keyboard { 13 | public static let outputTextView: AccessibilityID = "keyboard-text-view" 14 | public static let favoriteButton: AccessibilityID = "favorite-button" 15 | public static let saveButton: AccessibilityID = "checkmark-save-button" 16 | public static let view: AccessibilityID = "keyboard-view" 17 | 18 | public static func key(_ action: KeyboardKeyAction) -> AccessibilityID { 19 | switch action { 20 | case .insertCharacter(let char): 21 | AccessibilityID(stringLiteral: "keyboard_function_insert_character_\(char)") 22 | case .clear: 23 | AccessibilityID(stringLiteral: "keyboard_function_clear") 24 | case .backspace: 25 | AccessibilityID(stringLiteral: "keyboard_function_backspace") 26 | case .space: 27 | AccessibilityID(stringLiteral: "keyboard_function_space") 28 | case .speak: 29 | AccessibilityID(stringLiteral: "keyboard_function_speak") 30 | case .numberPad: 31 | AccessibilityID(stringLiteral: "keyboard_function_navigate_numpad") 32 | case .alphabet: 33 | AccessibilityID(stringLiteral: "keyboard_function_navigate_alphabet") 34 | case .openModifierPicker: 35 | AccessibilityID(stringLiteral: "keyboard_function_navigate_open_modifiers") 36 | case .closeModifierPicker: 37 | AccessibilityID(stringLiteral: "keyboard_function_navigate_close_modifiers") 38 | case .beginModifier(let mod): 39 | AccessibilityID(stringLiteral: "keyboard_function_begin_modifier_\(mod)") 40 | case .endModifier(let mod): 41 | AccessibilityID(stringLiteral: "keyboard_function_end_modifier_\(mod)") 42 | } 43 | } 44 | 45 | private init() {} 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Vocable/AppConfig/AppConfig.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppConfig.swift 3 | // Vocable AAC 4 | // 5 | // Created by Patrick Gatewood on 2/7/20. 6 | // Copyright © 2020 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | import ARKit 12 | import AVFoundation 13 | 14 | extension UserDefaultsKey { 15 | 16 | static let listeningModeEnabledPreference: UserDefaultsKey = "listeningModeEnabledPreference" 17 | static let listeningModeSmartAssistEnabledPreference: UserDefaultsKey = "listeningModeSmartAssistEnabledPreference" 18 | static let listeningModeHotWordEnabledPreference: UserDefaultsKey = "listeningModeHotWordEnabledPreference" 19 | static let sensitivitySetting: UserDefaultsKey = "sensitivitySetting" 20 | static let dwellDuration: UserDefaultsKey = "dwellDuration" 21 | static let isHeadTrackingEnabled: UserDefaultsKey = "isHeadTrackingEnabled" 22 | static let isCompactQWERTYKeyboardEnabled: UserDefaultsKey = "isCompactQWERTYKeyboardEnabled" 23 | static let selectedVoiceIdentifier: UserDefaultsKey = "selectedVoiceIdentifier" 24 | } 25 | 26 | struct AppConfig { 27 | 28 | static let showDebugOptions: Bool = { 29 | #if DEBUG 30 | return true 31 | #else 32 | return false 33 | #endif 34 | }() 35 | 36 | @PublishedDefault(.isHeadTrackingEnabled) 37 | static var isHeadTrackingEnabled: Bool = AppConfig.isHeadTrackingSupported 38 | static var isHeadTrackingSupported: Bool { 39 | return ARFaceTrackingConfiguration.isSupported 40 | } 41 | 42 | @PublishedDefault(.dwellDuration) 43 | static var selectionHoldDuration: TimeInterval = 1 44 | 45 | @PublishedDefault(.sensitivitySetting) 46 | static var cursorSensitivity: CursorSensitivity = CursorSensitivity.medium 47 | 48 | static let defaultLanguageCode = "en" 49 | static var activePreferredLanguageCode: String { 50 | return Locale.preferredLanguages.first ?? defaultLanguageCode 51 | } 52 | 53 | static let listeningMode = ListenModeFeatureConfiguration.shared 54 | 55 | @PublishedDefault(.isCompactQWERTYKeyboardEnabled) 56 | static var isCompactQWERTYKeyboardEnabled: Bool = false 57 | 58 | @PublishedDefault(.selectedVoiceIdentifier) 59 | static var selectedVoiceIdentifier: String? = .none 60 | } 61 | -------------------------------------------------------------------------------- /Vocable/Features/Voice/ListenModeDebugStorage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListenModeDebugStorage.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 3/19/21. 6 | // Copyright © 2021 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import VocableListenCore 11 | 12 | final class ListenModeDebugStorage: ObservableObject { 13 | 14 | static let shared = ListenModeDebugStorage() 15 | 16 | private init() { 17 | 18 | } 19 | 20 | private let maxHistoryCount = 50 21 | private static let defaultsKey = "loggingContextHistory" 22 | private var defaultsKey: String { 23 | return ListenModeDebugStorage.defaultsKey 24 | } 25 | 26 | private static func orderedContexts(_ contexts: [VLLoggingContext]) -> [VLLoggingContext] { 27 | let sorted = contexts.sorted { (lhs, rhs) -> Bool in 28 | guard let lhsStartDate = lhs.startDate, let rhsStartDate = rhs.startDate else { 29 | return false 30 | } 31 | return lhsStartDate > rhsStartDate 32 | } 33 | return sorted 34 | } 35 | 36 | private static func retrieveContexts() -> [VLLoggingContext] { 37 | guard let data = UserDefaults.standard.data(forKey: defaultsKey) else { 38 | return [] 39 | } 40 | guard let result = try? JSONDecoder().decode([VLLoggingContext].self, from: data) else { 41 | return [] 42 | } 43 | 44 | return orderedContexts(result) 45 | } 46 | 47 | func append(_ context: VLLoggingContext) { 48 | contexts.insert(context, at: 0) 49 | } 50 | 51 | func clear() { 52 | contexts = [] 53 | } 54 | 55 | func delete(at offsets: IndexSet) { 56 | contexts.remove(atOffsets: offsets) 57 | } 58 | 59 | @Published private(set) var contexts: [VLLoggingContext] = ListenModeDebugStorage.retrieveContexts() { 60 | didSet { 61 | let sorted = ListenModeDebugStorage.orderedContexts(contexts) 62 | let truncated = Array(sorted.suffix(maxHistoryCount)) 63 | guard let data = try? JSONEncoder().encode(truncated) else { 64 | assertionFailure("Failed to encode data") 65 | return 66 | } 67 | UserDefaults.standard.setValue(data, forKey: defaultsKey) 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Vocable/CoreData/Phrases.xcdatamodeld/Phrases v3.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Vocable/CoreData/Phrases.xcdatamodeld/Phrases v2.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Vocable/Extensions/TextExpression.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextExpression.swift 3 | // Vocable AAC 4 | // 5 | // Created by Kyle Ohanian on 4/16/19. 6 | // Copyright © 2019 WillowTree. All rights reserved. 7 | // 8 | import Foundation 9 | 10 | class TextExpression { 11 | private(set) var value: String 12 | 13 | private let textSuggestionController = TextSuggestionController() 14 | 15 | init(value: String = "") { 16 | self.value = value 17 | } 18 | 19 | var wordCount: Int { 20 | return value.split(separator: " ").count 21 | } 22 | 23 | var splitExpression: [String] { 24 | let trimmedExpression = self.value.trimmingCharacters(in: .whitespacesAndNewlines) 25 | return trimmedExpression.split(separator: " ").map { String($0) } 26 | } 27 | 28 | func add(word: String) { 29 | let trimmedExpression = self.value.trimmingCharacters(in: .whitespacesAndNewlines) 30 | var newSplitExpression = trimmedExpression.split(separator: " ").map { String($0) } 31 | newSplitExpression.append(word) 32 | self.value = newSplitExpression.joined(separator: " ") 33 | } 34 | 35 | func replaceWord(at index: Int, with newWord: String) { 36 | var newSplitExpression = self.splitExpression 37 | if newSplitExpression.count > index && index >= 0 { 38 | newSplitExpression[index] = newWord 39 | } 40 | self.value = newSplitExpression.joined(separator: " ") 41 | } 42 | 43 | func replaceLastWord(with newWord: String) { 44 | self.replaceWord(at: self.splitExpression.count - 1, with: newWord) 45 | } 46 | 47 | func word(at index: Int) -> String? { 48 | return self.splitExpression[safe: index] 49 | } 50 | 51 | func lastWord() -> String? { 52 | return word(at: self.splitExpression.count - 1) 53 | } 54 | 55 | func backspace() { 56 | if !self.value.isEmpty { 57 | _ = self.value.removeLast() 58 | } 59 | } 60 | 61 | func append(text: String) { 62 | value.append(text) 63 | } 64 | 65 | func replace(text: String) { 66 | value = text 67 | } 68 | 69 | func clear() { 70 | value = "" 71 | } 72 | 73 | func space() { 74 | self.value.append(" ") 75 | } 76 | 77 | func suggestions() -> [String] { 78 | return textSuggestionController.suggestions(for: self) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Vocable/Features/Settings/VoiceSettings/PersonalVoicePermissionPromptController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PersonalVoicePermissionPromptController.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 4/26/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AVFoundation 11 | import Combine 12 | import UIKit 13 | 14 | @available(iOS 17.0, *) 15 | final class PersonalVoicePermissionPromptController { 16 | 17 | private typealias AuthorizationStatus = AVSpeechSynthesizer.PersonalVoiceAuthorizationStatus 18 | 19 | struct PersonalVoicePermissionEmptyState { 20 | let state: PersonalVoiceEmptyState 21 | let action: EmptyStateView.ButtonConfiguration 22 | } 23 | 24 | @Published private(set) var state: PersonalVoicePermissionEmptyState? = .none 25 | 26 | private var cancellables = Set() 27 | 28 | private var authorizationStatus: AuthorizationStatus { 29 | AVSpeechSynthesizer.personalVoiceAuthorizationStatus 30 | } 31 | 32 | init() { 33 | self.authorizationStatusDidChange(authorizationStatus) 34 | NotificationCenter.default 35 | .publisher( 36 | for: AVSpeechSynthesizer.availableVoicesDidChangeNotification, 37 | object: nil 38 | ) 39 | .compactMap { [weak self] _ in 40 | self?.authorizationStatus 41 | } 42 | .receive(on: DispatchQueue.main) 43 | .sink { [weak self] (status) in 44 | self?.authorizationStatusDidChange(status) 45 | } 46 | .store(in: &cancellables) 47 | } 48 | 49 | private func authorizationStatusDidChange(_ status: AuthorizationStatus) { 50 | switch status { 51 | case .authorized: 52 | self.state = nil 53 | case .denied: // Need to go to settings 54 | self.state = .init(state: .denied) { 55 | UIApplication.openSettingsURL() 56 | } 57 | case .notDetermined: // Need to present alert 58 | self.state = .init(state: .notAuthorized) { [weak self] in 59 | AVSpeechSynthesizer.requestPersonalVoiceAuthorization { status in 60 | self?.authorizationStatusDidChange(status) 61 | } 62 | } 63 | default: 64 | self.state = nil 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Vocable/SwiftUI/UIKit Bridge/ContentHuggingPriority.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentHuggingPriority.swift 3 | // Vocable 4 | // 5 | // Created by Robert Moyer on 4/6/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import UIKit 11 | 12 | extension EnvironmentValues { 13 | private struct HorizontalContentHuggingPriorityKey: EnvironmentKey { 14 | static var defaultValue: UILayoutPriority = .defaultHigh 15 | } 16 | 17 | private struct VerticalContentHuggingPriorityKey: EnvironmentKey { 18 | static var defaultValue: UILayoutPriority = .defaultHigh 19 | } 20 | 21 | /// The priority with which a view associated with this 22 | /// environment resists being made larger than its intrinsic 23 | /// content size in the horizontal direction. 24 | /// 25 | /// The default value is `.defaultHigh`. 26 | var horizontalContentHuggingPriority: UILayoutPriority { 27 | get { self[HorizontalContentHuggingPriorityKey.self] } 28 | set { self[HorizontalContentHuggingPriorityKey.self] = newValue } 29 | } 30 | 31 | /// The priority with which a view associated with this 32 | /// environment resists being made larger than its intrinsic 33 | /// content size in the vertical direction. 34 | /// 35 | /// The default value is `.defaultHigh`. 36 | var verticalContentHuggingPriority: UILayoutPriority { 37 | get { self[VerticalContentHuggingPriorityKey.self] } 38 | set { self[VerticalContentHuggingPriorityKey.self] = newValue } 39 | } 40 | } 41 | 42 | extension View { 43 | /// Sets the content hugging priority for this view on the specified axis. 44 | /// 45 | /// - Parameters: 46 | /// - priority: The new priority. 47 | /// - axis: The axis for which the content hugging priority should be set. 48 | /// - Returns: A view that resists being made larger than its intrinsic content size, with the given priority level. 49 | @ViewBuilder func contentHuggingPriority( 50 | _ priority: UILayoutPriority, 51 | axis: NSLayoutConstraint.Axis 52 | ) -> some View { 53 | switch axis { 54 | case .vertical: 55 | environment(\.verticalContentHuggingPriority, priority) 56 | case .horizontal: 57 | environment(\.horizontalContentHuggingPriority, priority) 58 | @unknown default: 59 | self 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Vocable/Features/Keyboard/Keypad/Layout/Components/KeyboardLayoutBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardLayoutBuilders.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 5/24/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | @resultBuilder 12 | struct KeyboardBuilder { 13 | 14 | typealias Result = KeyboardBody 15 | typealias Row = (any KeyboardLayoutElement) 16 | 17 | struct Partial { 18 | var element: any KeyboardLayoutElement 19 | var lastRowIndex: Int = .zero 20 | 21 | init(element: any KeyboardLayoutElement, baseRowIndex: Int = .zero) { 22 | self.lastRowIndex = baseRowIndex 23 | self.element = element 24 | } 25 | 26 | init(elements: [any KeyboardLayoutElement], baseRowIndex: Int = .zero) { 27 | self.init( 28 | element: KeyboardLayoutGroup(children: elements), 29 | baseRowIndex: baseRowIndex 30 | ) 31 | } 32 | } 33 | 34 | // MARK: Keys 35 | 36 | static func buildBlock(_ components: Row...) -> Partial { 37 | Partial(elements: components) 38 | } 39 | 40 | static func buildExpression(_ expression: Row) -> Partial { 41 | Partial(element: expression) 42 | } 43 | 44 | static func buildExpression(_ expression: Partial) -> Partial { 45 | expression 46 | } 47 | 48 | static func buildEither(first component: Partial) -> Partial { 49 | component 50 | } 51 | 52 | static func buildEither(second component: Partial) -> Partial { 53 | component 54 | } 55 | 56 | static func buildArray(_ components: [any KeyboardLayoutElement]) -> Partial { 57 | Partial(elements: components) 58 | } 59 | 60 | static func buildArray(_ components: [Partial]) -> Partial { 61 | Partial(elements: components.map(\.element)) 62 | } 63 | 64 | static func buildOptional(_ component: Row?) -> Partial { 65 | Partial(elements: Array([component].compacted())) 66 | } 67 | 68 | static func buildPartialBlock(first: Partial) -> Partial { 69 | first 70 | } 71 | 72 | static func buildPartialBlock(accumulated: Partial, next: Partial) -> Partial { 73 | Partial(elements: [accumulated.element, next.element]) 74 | } 75 | 76 | static func buildFinalResult(_ component: Partial) -> Result { 77 | return Result(root: component.element) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Vocable/SwiftUI/Gaze Button/GazeButtonStyleConfiguration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GazeButtonStyleConfiguration.swift 3 | // Vocable 4 | // 5 | // Created by Robert Moyer on 4/7/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// The properties of a ``GazeButton`` instance. 12 | /// 13 | /// When you define a custom button style by creating a type that conforms to 14 | /// the ``GazeButtonStyle`` protocol, you implement the 15 | /// ``GazeButtonStyle/makeBody(_:)`` method. That method takes a 16 | /// `GazeButtonStyleConfiguration` input that has the information you need 17 | /// to define the behavior and appearance of a ``GazeButton``. 18 | /// 19 | /// The configuration structure's ``label-swift.property`` reflects the 20 | /// button's content, which might be the value that you supply to the 21 | /// `label` parameter of the ``GazeButton/init(minimumGazeDuration:role:action:label:)`` initializer. 22 | /// Alternatively, it could be another view that SwiftUI builds from an 23 | /// initializer that takes a string input, like ``GazeButton/init(_:minimumGazeDuration:role:action:)-8xknk``. 24 | /// In either case, incorporate the label into the button's view to help 25 | /// the user understand that the view is interactive. 26 | /// 27 | /// The structure's ``state`` property provides a `Binding` to the state 28 | /// of the button. Adjust the appearance of the button based on this value. 29 | /// For example, the built-in ``GazeButtonStyle/vocable`` style adds a thick 30 | /// border and shrinks the size when the vaule ``ButtonState/highlighted``. 31 | struct GazeButtonStyleConfiguration { 32 | 33 | /// A type-erased label of a ``GazeButton``. 34 | struct Label: View { 35 | private var view: AnyView 36 | var body: some View { view } 37 | 38 | fileprivate init(_ view: V) { 39 | self.view = AnyView(view) 40 | } 41 | } 42 | 43 | /// A binding to a state property that indicates whether the 44 | /// button is highlighted or pressed. 45 | let state: Binding 46 | 47 | /// A view that describes the effect of pressing the button. 48 | let label: Label 49 | 50 | /// An optional semantic role that describes the button’s purpose. 51 | let role: ButtonRole? 52 | 53 | init(label: V, state: Binding, role: ButtonRole?) { 54 | self.label = Label(label) 55 | self.state = state 56 | self.role = role 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Vocable/Features/Settings/VoiceSettings/VocableListContentConfiguration+VoiceProfileItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VoiceProfileCell.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 4/24/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AVFoundation 11 | import UIKit 12 | 13 | extension VocableListContentConfiguration { 14 | 15 | @MainActor 16 | static func voiceProfileItem( 17 | _ item: VoiceProfileItem, 18 | controller: VoiceProfilePreviewController, 19 | voiceSelectedAction: (() -> Void)? = nil 20 | ) -> Self { 21 | 22 | let sampleAction: VocableListCellAction 23 | if item.isPlaying { 24 | sampleAction = VocableListCellAction.stopAudio( 25 | accessibilityIdentifier: .settings.voiceSettings.audioPlaying 26 | ) { 27 | controller.stopPreview() 28 | } 29 | } else { 30 | sampleAction = VocableListCellAction.startAudio( 31 | accessibilityIdentifier: .settings.voiceSettings.playButton 32 | ) { 33 | controller.playPreview(item) 34 | } 35 | } 36 | 37 | return VocableListContentConfiguration( 38 | title: item.voice.name, 39 | actions: [sampleAction], 40 | trailingAccessory: (voiceSelectedAction != nil && item.isSelected) ? .checkmark : nil, 41 | isPrimaryActionEnabled: voiceSelectedAction != nil, 42 | accessibilityIdentifier: .settings.voiceSettings.previewVoiceCell, 43 | primaryAction: voiceSelectedAction ?? {} 44 | ) 45 | } 46 | 47 | @MainActor 48 | static func voiceSelectionPreview( 49 | _ item: VoiceProfileItem, 50 | controller: VoiceProfilePreviewController 51 | ) -> Self { 52 | 53 | let accessory: VocableListCellAccessory 54 | if item.isPlaying { 55 | accessory = .stopAudio 56 | } else { 57 | accessory = .playAudio 58 | } 59 | 60 | return VocableListContentConfiguration( 61 | title: item.voice.name, 62 | leadingAccessory: accessory, 63 | accessibilityIdentifier: AccessibilityID.settings.voiceSettings.previewVoiceCell 64 | ) { 65 | if item.isPlaying { 66 | controller.stopPreview() 67 | } else { 68 | controller.playPreview(item) 69 | } 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Vocable/Supporting Files/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | English 7 | CFBundleDisplayName 8 | Localized in Supporting Files > InfoPlist.strings 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | ITSAppUsesNonExemptEncryption 24 | 25 | LSRequiresIPhoneOS 26 | 27 | NSCameraUsageDescription 28 | Localized in Supporting Files > InfoPlist.strings 29 | NSMicrophoneUsageDescription 30 | Localized in Supporting Files > InfoPlist.strings 31 | NSSpeechRecognitionUsageDescription 32 | Localized in Supporting Files > InfoPlist.strings 33 | UIApplicationSceneManifest 34 | 35 | UIApplicationSupportsMultipleScenes 36 | 37 | 38 | UILaunchStoryboardName 39 | LaunchScreen 40 | UIMainStoryboardFile 41 | Root 42 | UIRequiredDeviceCapabilities 43 | 44 | armv7 45 | 46 | UIRequiresFullScreen 47 | 48 | UIStatusBarHidden 49 | 50 | UISupportedInterfaceOrientations 51 | 52 | UIInterfaceOrientationPortrait 53 | UIInterfaceOrientationLandscapeLeft 54 | UIInterfaceOrientationLandscapeRight 55 | 56 | UISupportedInterfaceOrientations~ipad 57 | 58 | UIInterfaceOrientationPortrait 59 | UIInterfaceOrientationPortraitUpsideDown 60 | UIInterfaceOrientationLandscapeLeft 61 | UIInterfaceOrientationLandscapeRight 62 | 63 | UIViewControllerBasedStatusBarAppearance 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /Vocable/Features/Settings/VoiceSettings/VoiceProfilePreviewDataSource+Filter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VoiceProfilePreviewDataSource+Filter.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 4/25/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AVFoundation 11 | 12 | extension VoiceProfilePreviewDataSource { 13 | 14 | // This is a cheap stand-in until the deployment target can 15 | // support iOS 17's Foundation Predicates. Goal is to allow 16 | // injecting a filter into the datasource externally so it can 17 | // be configured per-presentation context. 18 | struct Filter { 19 | 20 | private let isIncluded: ((voice: AVSpeechSynthesisVoice, isSelected: Bool)) -> Bool 21 | 22 | func shouldInclude(_ voice: AVSpeechSynthesisVoice, isSelected: Bool) -> Bool { 23 | isIncluded((voice: voice, isSelected: isSelected)) 24 | } 25 | } 26 | } 27 | 28 | extension VoiceProfilePreviewDataSource.Filter { 29 | 30 | /// Only allow the selected voice to remain in the collection 31 | static var selectedVoice: Self { 32 | Self { (_: AVSpeechSynthesisVoice, isSelected: Bool) in 33 | isSelected 34 | } 35 | } 36 | 37 | /// Only allow the standard selection of system voices to remain in the collection, 38 | /// excluding novelty voices. For pre-iOS 17.0 devices, this will filter any non-binary 39 | /// gendered voices because that appears to be the best (only?) strategy we have for 40 | /// filtering out novelty voices due to the lack of relevant metadata. 41 | static var systemVoices: Self { 42 | Self { (voice: AVSpeechSynthesisVoice, _: Bool) in 43 | if #available(iOS 17.0, *) { 44 | return !voice.voiceTraits.contains(.isNoveltyVoice) 45 | } else { 46 | // Best workaround we have, given the state of pre-iOS 17 APIs 47 | // Not trying to force a gender binary, merely trying to exclude 48 | // all the novelty voices without a proper API to do so. 49 | return [.male, .female].contains(voice.gender) 50 | } 51 | } 52 | } 53 | 54 | static var personalVoices: Self { 55 | Self { (voice: AVSpeechSynthesisVoice, _: Bool) in 56 | if #available(iOS 17.0, *) { 57 | return voice.voiceTraits.contains(.isPersonalVoice) 58 | } else { 59 | return false 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Tests/VocableUITests/Tests/KeyboardScreenTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardScreenTests.swift 3 | // KeyboardScreenTests 4 | // 5 | // Created by Kevin Stechler on 4/22/20. 6 | // Updated by Rudy Salas and Canan Arikan on 03/17/2022 7 | // Copyright © 2022 WillowTree. All rights reserved. 8 | // 9 | 10 | import XCTest 11 | 12 | class KeyboardScreenTests: BaseTest { 13 | 14 | func testKeyboardOutputIsDisplayed() throws { 15 | let testPhrase = "Test" 16 | 17 | try MainScreen.keyboardButton.tapWhenExists() 18 | try KeyboardScreen.typeText(testPhrase) 19 | 20 | try KeyboardScreen.keyboardTextView.staticTexts[testPhrase] 21 | .assertExistence("Expected the text \(testPhrase) to be displayed") 22 | } 23 | 24 | func testAddPhraseToMySayingsFromKeyboard() throws { 25 | let testPhrase = "Test" 26 | 27 | try MainScreen.keyboardButton.tapWhenExists() 28 | try KeyboardScreen.typeText(testPhrase) 29 | try KeyboardScreen.favoriteButton.tapWhenExists() 30 | try KeyboardScreen.navBarDismissButton.tapWhenExists() 31 | 32 | MainScreen.locateAndSelectDestinationCategory(.mySayings) 33 | 34 | try MainScreen.locatePhraseCell(phrase: testPhrase) 35 | .assertExistence("Expected the phrase \(testPhrase) to be added to and displayed in 'My Sayings'") 36 | } 37 | 38 | func testRemovePhraseFromMySayingsFromKeyboard() throws { 39 | let testPhrase = "Test" 40 | 41 | try MainScreen.keyboardButton.tapWhenExists() 42 | try KeyboardScreen.typeText(testPhrase) 43 | try KeyboardScreen.favoriteButton.tapWhenExists() 44 | try KeyboardScreen.navBarDismissButton.tapWhenExists() 45 | 46 | MainScreen.locateAndSelectDestinationCategory(.mySayings) 47 | 48 | try MainScreen.locatePhraseCell(phrase: testPhrase).assertExistence("Expected the phrase \(testPhrase) to be added to and displayed in 'My Sayings'") 49 | 50 | try MainScreen.keyboardButton.tapWhenExists() 51 | try KeyboardScreen.typeText(testPhrase) 52 | try KeyboardScreen.favoriteButton.tapWhenExists() 53 | try KeyboardScreen.navBarDismissButton.tapWhenExists() 54 | MainScreen.locateAndSelectDestinationCategory(.mySayings) 55 | 56 | // We expect 'My Sayings' to be empty now. 57 | try MainScreen.emptyStateAddPhraseButton.assertExistence("Expected the phrase \(testPhrase) to be deleted from 'My Sayings'") 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /Tests/VocableUITests/Tests/TimingAndSensitivityTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimingAndSensitivityTests.swift 3 | // VocableUITests 4 | // 5 | // Created by Alex Facer on 7/1/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import XCTest 11 | 12 | class TimingAndSensitivityTests: BaseTest { 13 | 14 | func testSwitchCursorSensitivity() { 15 | //navigate to timing and sensitivity screen and check medium selected by default 16 | MainScreen.settingsButton.tap() 17 | SettingsScreen.timingAndSensitivityCell.tap() 18 | XCTAssertTrue(TimingAndSensitivityScreen.mediumButton.isSelected) 19 | 20 | //Tap low button and check medium is not selected 21 | TimingAndSensitivityScreen.lowButton.tap() 22 | XCTAssertTrue(TimingAndSensitivityScreen.lowButton.isSelected) 23 | XCTAssertFalse(TimingAndSensitivityScreen.mediumButton.isSelected) 24 | 25 | //Tap high button and check low is not selected 26 | TimingAndSensitivityScreen.highButton.tap() 27 | XCTAssertTrue(TimingAndSensitivityScreen.highButton.isSelected) 28 | XCTAssertFalse(TimingAndSensitivityScreen.lowButton.isSelected) 29 | } 30 | 31 | func testHoverTimeButtons() { 32 | //navigate to timing and sensitivity screen and check hover time is 1.0 33 | MainScreen.settingsButton.tap() 34 | SettingsScreen.timingAndSensitivityCell.tap() 35 | XCTAssertEqual(TimingAndSensitivityScreen.hoverTimeLabel.label, "1.0s") 36 | XCTAssertTrue(TimingAndSensitivityScreen.increaseHoverTimeButton.isEnabled) 37 | XCTAssertTrue(TimingAndSensitivityScreen.reduceHoverTimeButton.isEnabled) 38 | 39 | //decrease hover time and check minus icon is disabled 40 | TimingAndSensitivityScreen.reduceHoverTimeButton.tap() 41 | XCTAssertFalse(TimingAndSensitivityScreen.reduceHoverTimeButton.isEnabled) 42 | 43 | //increase hover time and ensure decrease button becomes enabled 44 | TimingAndSensitivityScreen.increaseHoverTimeButton.tap() 45 | XCTAssertTrue(TimingAndSensitivityScreen.reduceHoverTimeButton.isEnabled) 46 | 47 | //increase hover time to max 5.0s and check that increase button disabled 48 | while TimingAndSensitivityScreen.hoverTimeLabel.label != "5.0s" { 49 | TimingAndSensitivityScreen.increaseHoverTimeButton.tap() 50 | } 51 | XCTAssertFalse(TimingAndSensitivityScreen.increaseHoverTimeButton.isEnabled) 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /Vocable/Common/VocableListCell/VocableListCellAccessory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccessoryAction.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 3/21/22. 6 | // Copyright © 2022 WillowTree. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | struct VocableListCellAccessory: Equatable { 12 | 13 | enum Content: Equatable { 14 | case toggle(isOn: Bool) 15 | case image(UIImage) 16 | 17 | static func == (lhs: Content, rhs: Content) -> Bool { 18 | switch (lhs, rhs) { 19 | case (.toggle(let isOnLeft), .toggle(let isOnRight)): 20 | return isOnLeft == isOnRight 21 | case (.image(let leftImage), .image(let rightImage)): 22 | return leftImage.isEqual(rightImage) 23 | default: 24 | return false 25 | } 26 | } 27 | } 28 | 29 | let content: Content 30 | let isEnabled: Bool 31 | 32 | private static var trailingDefaultSymbolConfiguration: UIImage.SymbolConfiguration { 33 | UIImage.SymbolConfiguration(pointSize: 28, weight: .bold) 34 | } 35 | 36 | static func disclosureIndicator(isEnabled: Bool = true) -> VocableListCellAccessory { 37 | let symbolName: String 38 | if UITraitCollection.current.layoutDirection == .leftToRight { 39 | symbolName = "chevron.right" 40 | } else { 41 | symbolName = "chevron.left" 42 | } 43 | return .systemImage(symbolName, isEnabled: isEnabled) 44 | } 45 | 46 | static func systemImage(_ name: String, isEnabled: Bool = true) -> VocableListCellAccessory { 47 | let image = UIImage(systemName: name, withConfiguration: trailingDefaultSymbolConfiguration)! 48 | return VocableListCellAccessory(content: .image(image), isEnabled: isEnabled) 49 | } 50 | 51 | static func toggle(isOn: Bool, isEnabled: Bool = true) -> VocableListCellAccessory { 52 | return VocableListCellAccessory(content: .toggle(isOn: isOn), isEnabled: isEnabled) 53 | } 54 | 55 | static var checkmark: VocableListCellAccessory { 56 | .checkmark(isEnabled: true) 57 | } 58 | 59 | static func checkmark(isEnabled: Bool) -> VocableListCellAccessory { 60 | .systemImage("checkmark", isEnabled: true) 61 | } 62 | 63 | static var playAudio: VocableListCellAccessory { 64 | .systemImage("play.circle", isEnabled: true) 65 | } 66 | 67 | static var stopAudio: VocableListCellAccessory { 68 | .systemImage("stop.circle", isEnabled: true) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Vocable/CoreData/NSManagedObject+Helpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSManagedObject+Helpers.swift 3 | // Vocable AAC 4 | // 5 | // Created by Chris Stroud on 2/21/20. 6 | // Copyright © 2020 WillowTree. All rights reserved. 7 | // 8 | 9 | import CoreData 10 | 11 | protocol NSManagedObjectIdentifiable { 12 | associatedtype IdentifierType 13 | } 14 | 15 | extension NSManagedObjectIdentifiable where Self: NSManagedObject { 16 | 17 | static func fetchObject(in context: NSManagedObjectContext, matching identifier: IdentifierType) -> Self? { 18 | guard let entityName = self.entity().name else { 19 | return nil 20 | } 21 | 22 | let fetchRequest = NSFetchRequest(entityName: entityName) 23 | if let identifier = identifier as? String { 24 | fetchRequest.predicate = NSPredicate(format: "identifier == %@", identifier) 25 | } else { 26 | fetchRequest.predicate = NSPredicate(format: "identifier == \(identifier)") 27 | } 28 | fetchRequest.fetchLimit = 1 29 | 30 | return (try? context.fetch(fetchRequest))?.first 31 | } 32 | 33 | static func fetchObject(in context: NSManagedObjectContext, matching identifier: NSManagedObjectID) -> Self? { 34 | guard let entityName = self.entity().name else { 35 | return nil 36 | } 37 | 38 | let fetchRequest = NSFetchRequest(entityName: entityName) 39 | fetchRequest.predicate = NSPredicate(format: "(SELF = %@)", identifier) 40 | fetchRequest.fetchLimit = 1 41 | 42 | return (try? context.fetch(fetchRequest))?.first 43 | } 44 | 45 | static func fetchAll(in context: NSManagedObjectContext, matching predicate: NSPredicate? = nil, sortDescriptors: [NSSortDescriptor]? = nil) -> [Self] { 46 | guard let entityName = self.entity().name else { 47 | return [] 48 | } 49 | 50 | let fetchRequest = NSFetchRequest(entityName: entityName) 51 | fetchRequest.sortDescriptors = sortDescriptors 52 | fetchRequest.predicate = predicate 53 | return (try? context.fetch(fetchRequest)) ?? [] 54 | } 55 | 56 | static func fetchOrCreate(in context: NSManagedObjectContext, matching identifier: IdentifierType) -> Self { 57 | if let existingObject = self.fetchObject(in: context, matching: identifier) { 58 | return existingObject 59 | } 60 | let newObject = self.init(context: context) 61 | newObject.setValue(identifier, forKeyPath: "identifier") 62 | return newObject 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /Vocable/Features/Keyboard/Keypad/Layout/CompactKeyboardLayoutEN.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CompactKeyboardLayoutEN.swift 3 | // Vocable 4 | // 5 | // Created by Chris Stroud on 6/12/24. 6 | // Copyright © 2024 WillowTree. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | import Collections 12 | 13 | struct CompactKeyboardLayoutEN: KeyboardLayout { 14 | 15 | let identifier = "compact_layout_EN" 16 | 17 | func makeLayout( 18 | configuration: KeyboardLayoutConfiguration 19 | ) -> KeyboardBody { 20 | switch configuration.mode { 21 | case .alphabetical: 22 | let values = "ABCDEFGHIJKLMNOPQRSTUVWXYZ',.?" 23 | KeyboardLayoutGroup { 24 | for row in values.chunks(ofCount: 6) { 25 | KeyboardLayoutRow { 26 | for key in row { 27 | KeyboardLayoutKey(key) 28 | } 29 | } 30 | } 31 | KeyboardLayoutRow { 32 | for key in "()!\"" { 33 | KeyboardLayoutKey(key) 34 | } 35 | KeyboardLayoutKey(.backspace) 36 | .keyWidth(span: 2) 37 | } 38 | } 39 | .keyWidth(count: 6) 40 | case .numerical: 41 | let values = "123456789" 42 | for row in values.chunks(ofCount: 3) { 43 | KeyboardLayoutRow { 44 | for key in row { 45 | KeyboardLayoutKey(key) 46 | .keyWidth(count: 3) 47 | } 48 | } 49 | } 50 | KeyboardLayoutRow { 51 | KeyboardLayoutKey(".") 52 | KeyboardLayoutKey("0") 53 | KeyboardLayoutKey(.backspace) 54 | } 55 | .keyWidth(count: 3) 56 | case .modifierPicker: 57 | KeyboardLayoutGroup { } 58 | } 59 | KeyboardLayoutRow { 60 | KeyboardLayoutKey( 61 | configuration.mode == .alphabetical ? .numberPad : .alphabet 62 | ) 63 | .keyWidth(span: 3) 64 | KeyboardLayoutKey(.space) 65 | .keyWidth(span: 6) 66 | KeyboardLayoutKey(.speak) 67 | .keyWidth(span: 3) 68 | } 69 | .keyWidth(count: 12) 70 | } 71 | } 72 | 73 | #Preview { 74 | KeyboardLayoutPreviewView( 75 | layout: CompactKeyboardLayoutEN(), 76 | mode: .alphabetical 77 | ) 78 | } 79 | --------------------------------------------------------------------------------