├── .githooks └── pre-commit ├── .gitignore ├── .swiftlint.yml ├── LICENSE ├── Library ├── .gitignore ├── Package.swift └── Sources │ ├── Configuration │ └── AppScene.swift │ ├── Resources │ ├── Colors │ │ └── Color+Theme.swift │ ├── Media.xcassets │ │ ├── Contents.json │ │ └── Themes │ │ │ ├── Contents.json │ │ │ ├── bubblegum.colorset │ │ │ └── Contents.json │ │ │ ├── buttercup.colorset │ │ │ └── Contents.json │ │ │ ├── indigo.colorset │ │ │ └── Contents.json │ │ │ ├── lavender.colorset │ │ │ └── Contents.json │ │ │ ├── magenta.colorset │ │ │ └── Contents.json │ │ │ ├── navy.colorset │ │ │ └── Contents.json │ │ │ ├── orange.colorset │ │ │ └── Contents.json │ │ │ ├── oxblood.colorset │ │ │ └── Contents.json │ │ │ ├── periwinkle.colorset │ │ │ └── Contents.json │ │ │ ├── poppy.colorset │ │ │ └── Contents.json │ │ │ ├── purple.colorset │ │ │ └── Contents.json │ │ │ ├── seafoam.colorset │ │ │ └── Contents.json │ │ │ ├── sky.colorset │ │ │ └── Contents.json │ │ │ ├── tan.colorset │ │ │ └── Contents.json │ │ │ ├── teal.colorset │ │ │ └── Contents.json │ │ │ └── yellow.colorset │ │ │ └── Contents.json │ ├── Strings │ │ ├── LocalizedStringKey+Constants.swift │ │ └── String+Constants.swift │ └── Symbols │ │ ├── Button+SFSymbol.swift │ │ ├── Image+SFSymbol.swift │ │ ├── Label+SFSymbol.swift │ │ └── ToolbarIconButton+SFSymbol.swift │ └── Scrum │ ├── Add+Edit │ ├── AddScreen.swift │ ├── EditScreen.swift │ └── Subviews │ │ ├── EditableAttendeesSection.swift │ │ ├── EditableMeetingInfoSection.swift │ │ ├── LengthSlider.swift │ │ ├── ThemePicker.swift │ │ └── TitleTextField.swift │ ├── Detail │ ├── DetailScreen.swift │ └── Subviews │ │ ├── AttendeeSection.swift │ │ ├── HistorySection.swift │ │ ├── MeetingInfoSection.swift │ │ ├── MeetingLengthRow.swift │ │ ├── StartMeetingRow.swift │ │ └── ThemeRow.swift │ ├── History │ └── HistoryScreen.swift │ ├── Meeting │ ├── MeetingScreen+View.swift │ ├── MeetingScreen.swift │ └── Subviews │ │ ├── MeetingFooterView.swift │ │ ├── MeetingHeaderView.swift │ │ ├── MeetingTimerView.swift │ │ └── SpeakerArc.swift │ ├── ScrumRoute.swift │ ├── ScrumScreen.swift │ └── Subviews │ ├── ScrumCard.swift │ └── ScrumList.swift ├── Models ├── .gitignore ├── Package.swift ├── Sources │ ├── Enumerations │ │ ├── RuntimeEnvironment.swift │ │ └── Theme.swift │ ├── Extensions │ │ ├── DailyScrum+SecondsPerSpeaker.swift │ │ ├── History+AttendeeString.swift │ │ └── TimeInterval+Minutes.swift │ ├── Models │ │ ├── DailyScrum.swift │ │ ├── DataStorage │ │ │ ├── DataStorage.swift │ │ │ ├── DataStore.swift │ │ │ └── StoredData.swift │ │ └── History.swift │ └── Protocols │ │ ├── Routable.swift │ │ ├── ToolbarView.swift │ │ └── ViewLifecycle.swift └── Tests │ └── ExtensionsTests │ ├── DailyScrumTests.swift │ └── HistoryTests.swift ├── README.md ├── Scrumdinger.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcuserdata │ │ └── jamessedlacek.xcuserdatad │ │ └── UserInterfaceState.xcuserstate ├── xcshareddata │ └── xcschemes │ │ ├── Scrumdinger.xcscheme │ │ └── ScrumdingerUITests.xcscheme └── xcuserdata │ └── jamessedlacek.xcuserdatad │ ├── xcdebugger │ └── Breakpoints_v2.xcbkptlist │ └── xcschemes │ └── xcschememanagement.plist ├── Scrumdinger ├── App │ └── ScrumdingerApp.swift ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── AppIcon1024@1x.png │ │ └── Contents.json │ └── Contents.json ├── Info.plist ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json └── TestPlan.xctestplan ├── ScrumdingerUITests ├── Elements │ ├── Button.swift │ ├── ProgressView.swift │ ├── Slider.swift │ ├── TextField.swift │ ├── Title.swift │ └── View.swift ├── ROBOT-PATTERN-README.md ├── Robots │ ├── AppRobot │ │ ├── AppRobot.swift │ │ └── Robot.swift │ └── ViewRobots │ │ ├── AddScrumRobot.swift │ │ ├── DetailScrumRobot.swift │ │ ├── HistoricalMeetingRobot.swift │ │ ├── MeetingScrumRobot.swift │ │ └── ScrumListRobot.swift └── Tests │ ├── AddScrumTests │ └── AddScrumTests.swift │ ├── DetailScrumTests │ └── DetailScrumTests.swift │ ├── EditScrumTests │ └── EditScrumTests.swift │ ├── MeetingScrumTests │ └── MeetingScrumTests.swift │ └── ScrumListTests │ └── ScrumListTests.swift ├── Services ├── .gitignore ├── Package.swift └── Sources │ ├── AudioService │ ├── AudioService.swift │ ├── AudioServiceError.swift │ ├── AudioServiceProtocol.swift │ ├── EnvironmentValues+AudioService.swift │ ├── MockAudioService.swift │ ├── Resources │ │ └── ding.wav │ └── SoundFile.swift │ ├── FileService │ ├── EnvironmentValues+FileService.swift │ ├── FileService.swift │ ├── FileServiceError.swift │ ├── FileServiceProtocol.swift │ └── MockFileService.swift │ └── SpeechService │ ├── EnvironmentValues+SpeechService.swift │ ├── MockSpeechService.swift │ ├── Permissions.swift │ ├── SpeechService.swift │ ├── SpeechServiceError.swift │ └── SpeechServiceProtocol.swift ├── UITests.xctestplan ├── ViewComponents ├── .gitignore ├── Package.swift └── Sources │ └── ViewComponents │ ├── Buttons │ ├── ToolbarButton.swift │ └── ToolbarIconButton.swift │ ├── Labels │ └── TrailingIconLabelStyle.swift │ └── ProgressViews │ └── RoundedProgressViewStyle.swift └── scripts └── install-hooks.sh /.githooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.swift$') 4 | 5 | if [ -z "$STAGED_FILES" ]; then 6 | exit 0 7 | fi 8 | 9 | echo "Running SwiftLint on staged Swift files..." 10 | 11 | PASS=true 12 | for file in $STAGED_FILES; do 13 | if [ -f "$file" ]; then 14 | swiftlint lint --strict --use-stdin < "$file" 15 | if [ $? -ne 0 ]; then 16 | PASS=false 17 | fi 18 | fi 19 | done 20 | 21 | if ! $PASS; then 22 | echo "❌ SwiftLint failed. Please fix violations before committing." 23 | exit 1 24 | fi 25 | 26 | exit 0 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # macOS System Files 2 | .DS_Store 3 | 4 | # Xcode 5 | *.xcworkspace 6 | *.xcuserstate 7 | *.xccheckout 8 | *.moved-aside 9 | *.xcscmblueprint 10 | DerivedData/ 11 | .idea/ 12 | 13 | # Swift Package Manager 14 | .swiftpm 15 | Package.resolved 16 | .build/ 17 | 18 | # CocoaPods 19 | Pods/ 20 | 21 | # Carthage 22 | Carthage/Build/ 23 | 24 | # Fastlane 25 | fastlane/report.xml 26 | fastlane/Preview.html 27 | fastlane/screenshots/**/*.png 28 | fastlane/test_output/ 29 | fastlane/*.lock 30 | 31 | # Firebase 32 | GoogleService-Info.plist 33 | 34 | # Temporary Files 35 | *.swp 36 | *.swo 37 | *.tmp 38 | 39 | # Logs 40 | *.log 41 | 42 | # Playgrounds 43 | timeline.xctimeline 44 | playground.xcworkspace 45 | 46 | # Archives 47 | *.xcarchive 48 | 49 | # Unit Test & Coverage 50 | *.gcda 51 | *.gcno 52 | *.gcov 53 | *.profdata 54 | 55 | # XCFrameworks 56 | *.xcframework 57 | 58 | # Custom Configurations 59 | Config.plist 60 | Config/*.plist 61 | *.xcconfig 62 | 63 | # Symbolication Files 64 | *.bcsymbolmap 65 | 66 | # App Store Distribution 67 | *.ipa 68 | *.dSYM.zip 69 | 70 | # Build Artifacts 71 | *.o 72 | *.so 73 | *.dylib 74 | *.a 75 | *.bin 76 | *.crash 77 | *.ll 78 | *.bc 79 | *.pdb 80 | *.exe 81 | 82 | # IDE Personal Settings 83 | *.mode1v3 84 | *.mode2v3 85 | *.perspectivev3 86 | 87 | # Breakpoints 88 | *.xcdebugger 89 | 90 | # Scheme Management 91 | xcuserdata/ 92 | *.xcscheme 93 | *.xcuserstate 94 | 95 | # Swift Package Artifacts 96 | *.xcodeproj/project.xcworkspace/xcuserdata 97 | 98 | # Vim/Editor Temporary Files 99 | *.swp 100 | *.swo 101 | 102 | # Visual Studio Code 103 | .vscode/ -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JamesSedlacek/Scrumdinger/d575833721b0428ab407ceef10a7d43cffb7ad9f/.swiftlint.yml -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 James Sedlacek 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 | -------------------------------------------------------------------------------- /Library/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Library/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Library", 8 | defaultLocalization: "en", 9 | platforms: [.iOS(.v17), .macOS(.v14)], 10 | products: PackageProduct.allCases.map(\.description), 11 | dependencies: SwiftPackage.allCases.map(\.packageDependency), 12 | targets: [ 13 | InternalTarget.allCases.map(\.target) 14 | ].flatMap { $0 } 15 | ) 16 | 17 | // MARK: PackageProduct 18 | 19 | private enum PackageProduct: CaseIterable { 20 | case scrumdinger 21 | 22 | var name: String { 23 | switch self { 24 | case .scrumdinger: "Scrumdinger" 25 | } 26 | } 27 | 28 | var targets: [InternalTarget] { 29 | switch self { 30 | case .scrumdinger: 31 | InternalTarget.allCases 32 | } 33 | } 34 | 35 | var description: PackageDescription.Product { 36 | switch self { 37 | case .scrumdinger: 38 | .library( 39 | name: self.name, 40 | targets: self.targets.map(\.title) 41 | ) 42 | } 43 | } 44 | } 45 | 46 | // MARK: InternalTarget 47 | 48 | private enum InternalTarget: CaseIterable { 49 | case configuration 50 | case resources 51 | case scrum 52 | 53 | var title: String { 54 | switch self { 55 | case .configuration: return "Configuration" 56 | case .resources: return "Resources" 57 | case .scrum: return "Scrum" 58 | } 59 | } 60 | 61 | var targetDependency: Target.Dependency { 62 | .target(name: title) 63 | } 64 | 65 | var dependencies: [Target.Dependency] { 66 | switch self { 67 | case .configuration: 68 | [ 69 | SwiftPackage.models.targetDependency, 70 | SwiftPackage.services.targetDependency 71 | ] 72 | case .resources: 73 | [ 74 | SwiftPackage.models.targetDependency, 75 | SwiftPackage.viewComponents.targetDependency 76 | ] 77 | case .scrum: 78 | [ 79 | InternalTarget.resources.targetDependency, 80 | SwiftPackage.models.targetDependency, 81 | SwiftPackage.services.targetDependency, 82 | SwiftPackage.viewComponents.targetDependency 83 | ] 84 | } 85 | } 86 | 87 | var resources: [Resource]? { 88 | switch self { 89 | case .resources: 90 | [ 91 | .process("Media.xcassets") 92 | ] 93 | default: nil 94 | } 95 | } 96 | 97 | var target: Target { 98 | .target( 99 | name: self.title, 100 | dependencies: self.dependencies, 101 | resources: self.resources 102 | ) 103 | } 104 | } 105 | 106 | // MARK: SwiftPackage 107 | 108 | private enum SwiftPackage: CaseIterable { 109 | case models 110 | case services 111 | case viewComponents 112 | 113 | var packageDependency: Package.Dependency { 114 | switch self { 115 | case .models: 116 | .package(path: "../Models") 117 | case .services: 118 | .package(path: "../Services") 119 | case .viewComponents: 120 | .package(path: "../ViewComponents") 121 | } 122 | } 123 | 124 | var targetDependency: Target.Dependency { 125 | switch self { 126 | case .models: 127 | .product(name: "Models", package: "Models") 128 | case .services: 129 | .product(name: "Services", package: "Services") 130 | case .viewComponents: 131 | .product(name: "ViewComponents", package: "ViewComponents") 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /Library/Sources/Configuration/AppScene.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppScene.swift 3 | // 4 | // Created by James Sedlacek on 2/14/25. 5 | // 6 | 7 | import AudioService 8 | import Enumerations 9 | import FileService 10 | import Models 11 | import SpeechService 12 | import SwiftUI 13 | 14 | /// AppScene serves as the root configuration point for the application. 15 | /// 16 | /// This scene is responsible for: 17 | /// - Setting up and providing access to core services (Speech, Audio, File) 18 | /// - Managing environment dependencies based on runtime environment 19 | /// - Configuring the main window group and its environment 20 | /// - Handling the distinction between physical device and simulator environments 21 | /// 22 | /// Usage: 23 | /// ```swift 24 | /// @main 25 | /// struct YourApp: App { 26 | /// var body: some Scene { 27 | /// AppScene(content: YourRootView.init) 28 | /// } 29 | /// } 30 | /// ``` 31 | @MainActor 32 | public struct AppScene: Scene { 33 | private let content: () -> Content 34 | private let speechService: any SpeechServiceProtocol 35 | private let audioService: any AudioServiceProtocol 36 | private let fileService: any FileServiceProtocol 37 | @DataStore private var dailyScrums: [DailyScrum] = [] 38 | 39 | /// Creates a new AppScene with the specified root content. 40 | /// - Parameter content: A closure that returns the root view of the application. 41 | public init(content: @escaping () -> Content) { 42 | self.content = content 43 | 44 | print(RuntimeEnvironment.current.description) 45 | 46 | if RuntimeEnvironment.current.isPhysicalDevice { 47 | self.speechService = SpeechService() 48 | self.audioService = AudioService() 49 | self.fileService = FileService() 50 | } else { 51 | self.speechService = MockSpeechService() 52 | self.audioService = MockAudioService() 53 | self.fileService = MockFileService() 54 | } 55 | } 56 | 57 | public var body: some Scene { 58 | WindowGroup { 59 | content() 60 | } 61 | .environment(_dailyScrums.storage) 62 | .environment(\.speechService, speechService) 63 | .environment(\.audioService, audioService) 64 | .environment(\.fileService, fileService) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Library/Sources/Resources/Colors/Color+Theme.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Color+Theme.swift 3 | // 4 | // Created by James Sedlacek on 2/15/25. 5 | // 6 | 7 | import Enumerations 8 | import SwiftUI 9 | 10 | extension Color { 11 | init(_ theme: Theme) { 12 | self.init(theme.rawValue, bundle: .module) 13 | } 14 | } 15 | 16 | extension Theme { 17 | public var mainColor: Color { 18 | .init(self) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Library/Sources/Resources/Media.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Library/Sources/Resources/Media.xcassets/Themes/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Library/Sources/Resources/Media.xcassets/Themes/bubblegum.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.820", 9 | "green" : "0.502", 10 | "red" : "0.933" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.820", 27 | "green" : "0.502", 28 | "red" : "0.933" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Library/Sources/Resources/Media.xcassets/Themes/buttercup.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.588", 9 | "green" : "0.945", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.588", 27 | "green" : "0.945", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Library/Sources/Resources/Media.xcassets/Themes/indigo.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.443", 9 | "green" : "0.000", 10 | "red" : "0.212" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.443", 27 | "green" : "0.000", 28 | "red" : "0.212" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Library/Sources/Resources/Media.xcassets/Themes/lavender.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "0.808", 10 | "red" : "0.812" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "0.808", 28 | "red" : "0.812" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Library/Sources/Resources/Media.xcassets/Themes/magenta.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.467", 9 | "green" : "0.075", 10 | "red" : "0.647" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.467", 27 | "green" : "0.075", 28 | "red" : "0.647" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Library/Sources/Resources/Media.xcassets/Themes/navy.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.255", 9 | "green" : "0.078", 10 | "red" : "0.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.255", 27 | "green" : "0.078", 28 | "red" : "0.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Library/Sources/Resources/Media.xcassets/Themes/orange.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.259", 9 | "green" : "0.545", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.259", 27 | "green" : "0.545", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Library/Sources/Resources/Media.xcassets/Themes/oxblood.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.043", 9 | "green" : "0.027", 10 | "red" : "0.290" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.043", 27 | "green" : "0.027", 28 | "red" : "0.290" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Library/Sources/Resources/Media.xcassets/Themes/periwinkle.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "0.510", 10 | "red" : "0.525" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "0.510", 28 | "red" : "0.525" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Library/Sources/Resources/Media.xcassets/Themes/poppy.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.369", 9 | "green" : "0.369", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.369", 27 | "green" : "0.369", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Library/Sources/Resources/Media.xcassets/Themes/purple.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.949", 9 | "green" : "0.294", 10 | "red" : "0.569" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.949", 27 | "green" : "0.294", 28 | "red" : "0.569" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Library/Sources/Resources/Media.xcassets/Themes/seafoam.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.898", 9 | "green" : "0.918", 10 | "red" : "0.796" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.898", 27 | "green" : "0.918", 28 | "red" : "0.796" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Library/Sources/Resources/Media.xcassets/Themes/sky.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "0.573", 10 | "red" : "0.431" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "0.573", 28 | "red" : "0.431" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Library/Sources/Resources/Media.xcassets/Themes/tan.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.494", 9 | "green" : "0.608", 10 | "red" : "0.761" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.494", 27 | "green" : "0.608", 28 | "red" : "0.761" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Library/Sources/Resources/Media.xcassets/Themes/teal.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.620", 9 | "green" : "0.561", 10 | "red" : "0.133" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.620", 27 | "green" : "0.561", 28 | "red" : "0.133" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Library/Sources/Resources/Media.xcassets/Themes/yellow.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.302", 9 | "green" : "0.875", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.302", 27 | "green" : "0.875", 28 | "red" : "1.000" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Library/Sources/Resources/Strings/LocalizedStringKey+Constants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocalizedStringKey+Constants.swift 3 | // 4 | // Created by James Sedlacek on 2/14/25. 5 | // 6 | 7 | import SwiftUI 8 | 9 | @MainActor 10 | extension LocalizedStringKey { 11 | // MARK: - A 12 | public static let add = LocalizedStringKey("Add") 13 | public static let addAttendee = LocalizedStringKey("Add Attendee") 14 | public static let attendees = LocalizedStringKey("Attendees") 15 | 16 | // MARK: - C 17 | public static let cancel = LocalizedStringKey("Cancel") 18 | 19 | // MARK: - D 20 | public static let dailyScrums = LocalizedStringKey("Daily Scrums") 21 | public static let dismiss = LocalizedStringKey("Dismiss") 22 | public static let done = LocalizedStringKey("Done") 23 | 24 | // MARK: - E 25 | public static let edit = LocalizedStringKey("Edit") 26 | public static let empty = LocalizedStringKey("") 27 | 28 | // MARK: - H 29 | public static let history = LocalizedStringKey("History") 30 | 31 | // MARK: - I 32 | public static let isSpeaking = LocalizedStringKey("is speaking") 33 | 34 | // MARK: - L 35 | public static let lastSpeaker = LocalizedStringKey("Last Speaker") 36 | public static let length = LocalizedStringKey("Length") 37 | 38 | // MARK: - M 39 | public static let meetingInfo = LocalizedStringKey("Meeting Info") 40 | 41 | // MARK: - N 42 | public static let newAttendee = LocalizedStringKey("New Attendee") 43 | public static let nextSpeaker = LocalizedStringKey("Next speaker") 44 | public static let noMeetingsYet = LocalizedStringKey("No meetings yet") 45 | public static let noSpeakers = LocalizedStringKey("No more speakers") 46 | 47 | // MARK: O 48 | // swiftlint:disable:next identifier_name 49 | public static let ok = LocalizedStringKey("OK") 50 | 51 | // MARK: - S 52 | public static let secondsElapsed = LocalizedStringKey("Seconds Elapsed") 53 | public static let secondsRemaining = LocalizedStringKey("Seconds Remaining") 54 | public static let someone = LocalizedStringKey("Someone") 55 | public static let startMeeting = LocalizedStringKey("Start Meeting") 56 | 57 | // MARK: - T 58 | public static let theme = LocalizedStringKey("Theme") 59 | public static let timeRemaining = LocalizedStringKey("Time remaining") 60 | public static let title = LocalizedStringKey("Title") 61 | public static let transcript = LocalizedStringKey("Transcript") 62 | 63 | // MARK: - W 64 | public static let withTranscription = LocalizedStringKey("with transcription") 65 | public static let withoutTranscription = LocalizedStringKey("without transcription") 66 | 67 | // MARK: - Functions 68 | public static func attendeeCount(_ count: Int) -> LocalizedStringKey { 69 | LocalizedStringKey("\(count) attendees") 70 | } 71 | 72 | public static func meetingLength(_ minutes: Int) -> LocalizedStringKey { 73 | LocalizedStringKey("\(minutes) minute meeting") 74 | } 75 | 76 | public static func minutes(_ count: Int) -> LocalizedStringKey { 77 | LocalizedStringKey("\(count) \(count == 1 ? "minute" : "minutes")") 78 | } 79 | 80 | public static func speakerCount(_ current: Int, _ total: Int) -> LocalizedStringKey { 81 | LocalizedStringKey("Speaker \(current) of \(total)") 82 | } 83 | 84 | public static func timeRemaining(minutes: Int) -> LocalizedStringKey { 85 | LocalizedStringKey("\(minutes) minutes") 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Library/Sources/Resources/Strings/String+Constants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Constants.swift 3 | // 4 | // Created by James Sedlacek on 2/22/25. 5 | // 6 | 7 | extension String { 8 | public static let dailyScrumsKey = "dailyScrumsKey" 9 | } 10 | -------------------------------------------------------------------------------- /Library/Sources/Resources/Symbols/Button+SFSymbol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Button+SFSymbol.swift 3 | // 4 | // Created by James Sedlacek on 2/18/25. 5 | // 6 | 7 | import SwiftUI 8 | 9 | extension Button where Label == SwiftUI.Label { 10 | package nonisolated init( 11 | _ titleKey: LocalizedStringKey, 12 | symbol: Image.SFSymbol, 13 | action: @escaping @MainActor () -> Void 14 | ) { 15 | self.init( 16 | titleKey, 17 | systemImage: symbol.rawValue, 18 | action: action 19 | ) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Library/Sources/Resources/Symbols/Image+SFSymbol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Image+SFSymbol.swift 3 | // 4 | // Created by James Sedlacek on 10/30/24. 5 | // 6 | 7 | import SwiftUI 8 | 9 | extension Image { 10 | package enum SFSymbol: String { 11 | case calendar 12 | case calendarBadgeExclamationmark = "calendar.badge.exclamationmark" 13 | case clock 14 | case forward = "forward.fill" 15 | case hourglassBottom = "hourglass.bottomhalf.fill" 16 | case hourglassTop = "hourglass.tophalf.fill" 17 | case micActive = "mic" 18 | case micInactive = "mic.slash" 19 | case paintpalette 20 | case person 21 | case person3 = "person.3" 22 | case plus 23 | case timer 24 | } 25 | 26 | package init(_ symbol: SFSymbol) { 27 | self.init(systemName: symbol.rawValue) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Library/Sources/Resources/Symbols/Label+SFSymbol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Label+SFSymbol.swift 3 | // 4 | // Created by James Sedlacek on 2/18/25. 5 | // 6 | 7 | import SwiftUI 8 | 9 | extension Label where Title == Text, Icon == Image { 10 | package nonisolated init( 11 | _ titleKey: LocalizedStringKey, 12 | symbol: Image.SFSymbol 13 | ) { 14 | self.init(titleKey, systemImage: symbol.rawValue) 15 | } 16 | 17 | package nonisolated init( 18 | _ titleKey: String, 19 | symbol: Image.SFSymbol 20 | ) { 21 | self.init(titleKey, systemImage: symbol.rawValue) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Library/Sources/Resources/Symbols/ToolbarIconButton+SFSymbol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToolbarIconButton+SFSymbol.swift 3 | // 4 | // Created by James Sedlacek on 2/20/25. 5 | // 6 | 7 | import SwiftUI 8 | import ViewComponents 9 | 10 | extension ToolbarIconButton { 11 | package init( 12 | symbol: Image.SFSymbol, 13 | placement: ToolbarItemPlacement = .confirmationAction, 14 | perform action: @escaping () -> Void 15 | ) { 16 | self.init( 17 | systemName: symbol.rawValue, 18 | placement: placement, 19 | perform: action 20 | ) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Library/Sources/Scrum/Add+Edit/AddScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AddScreen.swift 3 | // 4 | // Created by James Sedlacek on 2/14/25. 5 | // 6 | 7 | import Enumerations 8 | import Extensions 9 | import FileService 10 | import Models 11 | import Protocols 12 | import Resources 13 | import SwiftUI 14 | import ViewComponents 15 | 16 | @MainActor 17 | struct AddScreen { 18 | @Environment(\.dismiss) private var dismiss 19 | @Environment(\.fileService) private var fileService 20 | @StoredData private var dailyScrums: [DailyScrum] 21 | @State private var title: String = "" 22 | @State private var attendees: [DailyScrum.Attendee] = [] 23 | @State private var length: TimeInterval = .minutes(5) 24 | @State private var theme: Theme = .seafoam 25 | @State private var errorToPresent: FileServiceError? 26 | 27 | private var isErrorPresented: Binding { 28 | .constant(errorToPresent != nil) 29 | } 30 | 31 | private func dismissAction() { 32 | dismiss() 33 | } 34 | 35 | private func addAction() { 36 | do throws(FileServiceError) { 37 | let newScrum: DailyScrum = .init( 38 | title: title, 39 | attendees: attendees.map(\.name), 40 | length: length, 41 | theme: theme 42 | ) 43 | 44 | _dailyScrums.upsert(newScrum) 45 | try fileService.save(dailyScrums, forKey: .dailyScrumsKey) 46 | dismiss() 47 | } catch { 48 | errorToPresent = error 49 | } 50 | } 51 | } 52 | 53 | extension AddScreen: View { 54 | var body: some View { 55 | NavigationStack { 56 | Form { 57 | EditableMeetingInfoSection( 58 | title: $title, 59 | length: $length, 60 | theme: $theme 61 | ) 62 | 63 | EditableAttendeesSection( 64 | attendees: $attendees 65 | ) 66 | } 67 | .navigationTitle(.empty) 68 | .toolbar(content: toolbarContent) 69 | .alert( 70 | isPresented: isErrorPresented, 71 | error: errorToPresent, 72 | actions: errorActions, 73 | message: errorMessage 74 | ) 75 | } 76 | } 77 | 78 | private func errorMessage(for error: LocalizedError) -> some View { 79 | Text(error.errorDescription ?? "Unknown error") 80 | } 81 | 82 | private func errorActions(for error: LocalizedError) -> some View { 83 | Button(.ok) {} 84 | } 85 | } 86 | 87 | extension AddScreen: ToolbarView { 88 | public func toolbarContent() -> some ToolbarContent { 89 | ToolbarButton( 90 | .dismiss, 91 | placement: .cancellationAction, 92 | perform: dismissAction 93 | ) 94 | 95 | ToolbarButton(.add, perform: addAction) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Library/Sources/Scrum/Add+Edit/EditScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EditScreen.swift 3 | // 4 | // Created by James Sedlacek on 2/14/25. 5 | // 6 | 7 | import Enumerations 8 | import FileService 9 | import Models 10 | import Protocols 11 | import Resources 12 | import SwiftUI 13 | import ViewComponents 14 | 15 | @MainActor 16 | struct EditScreen { 17 | private let scrum: DailyScrum 18 | @Environment(\.dismiss) private var dismiss 19 | @Environment(\.fileService) private var fileService 20 | @StoredData private var dailyScrums: [DailyScrum] 21 | @State private var title: String 22 | @State private var attendees: [DailyScrum.Attendee] 23 | @State private var length: TimeInterval 24 | @State private var theme: Theme 25 | @State private var errorToPresent: FileServiceError? 26 | 27 | private var isErrorPresented: Binding { 28 | .constant(errorToPresent != nil) 29 | } 30 | 31 | init(_ scrum: DailyScrum) { 32 | self.scrum = scrum 33 | _title = State(initialValue: scrum.title) 34 | _attendees = State(initialValue: scrum.attendees) 35 | _length = State(initialValue: Double(scrum.length)) 36 | _theme = State(initialValue: scrum.theme) 37 | } 38 | 39 | private func cancelAction() { 40 | dismiss() 41 | } 42 | 43 | private func doneAction() { 44 | do throws(FileServiceError) { 45 | let updatedScrum = DailyScrum( 46 | id: scrum.id, 47 | title: title, 48 | attendees: attendees.map(\.name), 49 | length: length, 50 | theme: theme 51 | ) 52 | _dailyScrums.upsert(updatedScrum) 53 | try fileService.save(dailyScrums, forKey: .dailyScrumsKey) 54 | dismiss() 55 | } catch { 56 | errorToPresent = error 57 | } 58 | } 59 | } 60 | 61 | extension EditScreen: View { 62 | var body: some View { 63 | NavigationStack { 64 | Form { 65 | EditableMeetingInfoSection( 66 | title: $title, 67 | length: $length, 68 | theme: $theme 69 | ) 70 | 71 | EditableAttendeesSection( 72 | attendees: $attendees 73 | ) 74 | } 75 | .navigationTitle(scrum.title) 76 | .toolbar(content: toolbarContent) 77 | } 78 | } 79 | } 80 | 81 | extension EditScreen: ToolbarView { 82 | public func toolbarContent() -> some ToolbarContent { 83 | ToolbarButton( 84 | .cancel, 85 | placement: .cancellationAction, 86 | perform: cancelAction 87 | ) 88 | 89 | ToolbarButton(.done, perform: doneAction) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Library/Sources/Scrum/Add+Edit/Subviews/EditableAttendeesSection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AttendeesSection.swift 3 | // 4 | // Created by James Sedlacek on 2/21/25. 5 | // 6 | 7 | import Models 8 | import SwiftUI 9 | 10 | @MainActor 11 | struct EditableAttendeesSection { 12 | @Binding private var attendees: [DailyScrum.Attendee] 13 | @State private var newAttendeeName: String = "" 14 | 15 | private var addAttendeeButtonDisabled: Bool { 16 | newAttendeeName.isEmpty 17 | } 18 | 19 | init(attendees: Binding<[DailyScrum.Attendee]>) { 20 | self._attendees = attendees 21 | } 22 | 23 | private func addAttendee() { 24 | withAnimation { 25 | let attendee = DailyScrum.Attendee(name: newAttendeeName) 26 | attendees.append(attendee) 27 | newAttendeeName = "" 28 | } 29 | } 30 | 31 | private func onDeleteAction(_ indices: IndexSet) { 32 | attendees.remove(atOffsets: indices) 33 | } 34 | } 35 | 36 | extension EditableAttendeesSection: View { 37 | var body: some View { 38 | Section(.attendees) { 39 | ForEach(attendees) { attendee in 40 | Text(attendee.name) 41 | } 42 | .onDelete(perform: onDeleteAction) 43 | 44 | newAttendeeRow 45 | } 46 | } 47 | 48 | private var newAttendeeRow: some View { 49 | HStack { 50 | TextField(.newAttendee, text: $newAttendeeName) 51 | 52 | Button(.addAttendee, symbol: .plus, action: addAttendee) 53 | .labelStyle(.iconOnly) 54 | .symbolVariant(.circle.fill) 55 | .imageScale(.medium) 56 | .disabled(addAttendeeButtonDisabled) 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Library/Sources/Scrum/Add+Edit/Subviews/EditableMeetingInfoSection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EditableMeetingInfoSection.swift 3 | // 4 | // Created by James Sedlacek on 2/21/25. 5 | // 6 | 7 | import Enumerations 8 | import SwiftUI 9 | 10 | @MainActor 11 | struct EditableMeetingInfoSection: View { 12 | @Binding private var title: String 13 | @Binding private var length: TimeInterval 14 | @Binding private var theme: Theme 15 | 16 | init( 17 | title: Binding, 18 | length: Binding, 19 | theme: Binding 20 | ) { 21 | self._title = title 22 | self._length = length 23 | self._theme = theme 24 | } 25 | 26 | var body: some View { 27 | Section(.meetingInfo) { 28 | TitleTextField(title: $title) 29 | LengthSlider(length: $length) 30 | ThemePicker(selection: $theme) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Library/Sources/Scrum/Add+Edit/Subviews/LengthSlider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LengthSlider.swift 3 | // 4 | // Created by James Sedlacek on 2/21/25. 5 | // 6 | 7 | import Extensions 8 | import SwiftUI 9 | 10 | @MainActor 11 | struct LengthSlider: View { 12 | @Binding var length: TimeInterval 13 | 14 | var body: some View { 15 | HStack { 16 | Slider( 17 | value: $length, 18 | in: .minutes(5)...TimeInterval.minutes(30), 19 | step: .minutes(1) 20 | ) { 21 | Text(.length) 22 | } 23 | .accessibilityValue(.minutes(length.minutes)) 24 | 25 | Spacer() 26 | 27 | Text(.minutes(length.minutes)) 28 | .accessibilityHidden(true) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Library/Sources/Scrum/Add+Edit/Subviews/ThemePicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThemePicker.swift 3 | // 4 | // Created by James Sedlacek on 2/21/25. 5 | // 6 | 7 | import Enumerations 8 | import SwiftUI 9 | 10 | @MainActor 11 | struct ThemePicker: View { 12 | @Binding var selection: Theme 13 | 14 | var body: some View { 15 | Picker(.theme, selection: $selection) { 16 | ForEach(Theme.allCases) { theme in 17 | Label(theme.name, symbol: .paintpalette) 18 | .tag(theme) 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Library/Sources/Scrum/Add+Edit/Subviews/TitleTextField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TitleTextField.swift 3 | // 4 | // Created by James Sedlacek on 2/21/25. 5 | // 6 | 7 | import SwiftUI 8 | 9 | @MainActor 10 | struct TitleTextField: View { 11 | @Binding var title: String 12 | 13 | var body: some View { 14 | TextField(.title, text: $title) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Library/Sources/Scrum/Detail/DetailScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DetailScreen.swift 3 | // 4 | // Created by James Sedlacek on 2/14/25. 5 | // 6 | 7 | import Models 8 | import Protocols 9 | import Resources 10 | import SwiftUI 11 | import ViewComponents 12 | 13 | @MainActor 14 | struct DetailScreen { 15 | @StoredData private var scrumPath: [ScrumRoute] 16 | @StoredData private var dailyScrums: [DailyScrum] 17 | @State private var scrum: DailyScrum 18 | @State private var routeToPresent: ScrumRoute? 19 | 20 | init(_ scrum: DailyScrum) { 21 | _scrum = .init(initialValue: scrum) 22 | } 23 | 24 | private func startMeetingAction() { 25 | scrumPath.append(.meeting(scrum)) 26 | } 27 | 28 | private func editAction() { 29 | routeToPresent = .edit(scrum) 30 | } 31 | 32 | private func historyAction(_ history: History) { 33 | scrumPath.append(.history(history)) 34 | } 35 | 36 | private func onScrumChange() { 37 | guard let updatedScrum = dailyScrums.first( 38 | where: { $0.id == scrum.id } 39 | ) else { return } 40 | 41 | scrum = updatedScrum 42 | } 43 | } 44 | 45 | extension DetailScreen: View { 46 | var body: some View { 47 | List { 48 | MeetingInfoSection( 49 | scrum: scrum, 50 | startMeetingAction: startMeetingAction 51 | ) 52 | 53 | AttendeeSection( 54 | attendees: scrum.attendees 55 | ) 56 | 57 | HistorySection( 58 | history: scrum.history, 59 | onHistoryTap: historyAction 60 | ) 61 | } 62 | .onChange(of: dailyScrums, onScrumChange) 63 | .navigationTitle(scrum.title) 64 | .sheet( 65 | item: $routeToPresent, 66 | content: ScrumRoute.destination 67 | ) 68 | .toolbar(content: toolbarContent) 69 | } 70 | } 71 | 72 | extension DetailScreen: ToolbarView { 73 | public func toolbarContent() -> some ToolbarContent { 74 | ToolbarButton(.edit, perform: editAction) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Library/Sources/Scrum/Detail/Subviews/AttendeeSection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AttendeeSection.swift 3 | // 4 | // Created by James Sedlacek on 2/21/25. 5 | // 6 | 7 | import Models 8 | import SwiftUI 9 | 10 | @MainActor 11 | struct AttendeeSection: View { 12 | let attendees: [DailyScrum.Attendee] 13 | 14 | var body: some View { 15 | Section(.attendees) { 16 | ForEach(attendees) { attendee in 17 | Label(attendee.name, symbol: .person) 18 | .accessibilityIdentifier(attendee.name) 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Library/Sources/Scrum/Detail/Subviews/HistorySection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HistorySection.swift 3 | // 4 | // Created by James Sedlacek on 2/21/25. 5 | // 6 | 7 | import Models 8 | import SwiftUI 9 | 10 | @MainActor 11 | struct HistorySection: View { 12 | let history: [History] 13 | let onHistoryTap: (History) -> Void 14 | 15 | private var isShowingEmptyHistory: Bool { 16 | history.isEmpty 17 | } 18 | 19 | var body: some View { 20 | Section(.history) { 21 | if isShowingEmptyHistory { 22 | Label(.noMeetingsYet, symbol: .calendarBadgeExclamationmark) 23 | } else { 24 | ForEach(history) { history in 25 | HStack { 26 | Image(.calendar) 27 | Text(history.date, style: .date) 28 | } 29 | .contentShape(.rect) 30 | .onTapGesture { 31 | onHistoryTap(history) 32 | } 33 | } 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Library/Sources/Scrum/Detail/Subviews/MeetingInfoSection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MeetingInfoSection.swift 3 | // 4 | // Created by James Sedlacek on 2/21/25. 5 | // 6 | 7 | import Enumerations 8 | import Models 9 | import SwiftUI 10 | 11 | @MainActor 12 | struct MeetingInfoSection: View { 13 | let scrum: DailyScrum 14 | let startMeetingAction: () -> Void 15 | 16 | var body: some View { 17 | Section(.meetingInfo) { 18 | StartMeetingRow(action: startMeetingAction) 19 | MeetingLengthRow(length: scrum.length) 20 | ThemeRow(theme: scrum.theme) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Library/Sources/Scrum/Detail/Subviews/MeetingLengthRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MeetingLengthRow.swift 3 | // 4 | // Created by James Sedlacek on 2/21/25. 5 | // 6 | 7 | import Extensions 8 | import SwiftUI 9 | 10 | @MainActor 11 | struct MeetingLengthRow: View { 12 | let length: TimeInterval 13 | 14 | var body: some View { 15 | HStack { 16 | Label(.length, symbol: .clock) 17 | Spacer() 18 | Text(.minutes(length.minutes)) 19 | } 20 | .accessibilityElement(children: .combine) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Library/Sources/Scrum/Detail/Subviews/StartMeetingRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StartMeetingRow.swift 3 | // 4 | // Created by James Sedlacek on 2/21/25. 5 | // 6 | 7 | import SwiftUI 8 | 9 | @MainActor 10 | struct StartMeetingRow: View { 11 | let action: () -> Void 12 | 13 | var body: some View { 14 | Label(.startMeeting, symbol: .timer) 15 | .font(.headline) 16 | .foregroundStyle(Color.accentColor) 17 | .contentShape(.rect) 18 | .onTapGesture(perform: action) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Library/Sources/Scrum/Detail/Subviews/ThemeRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ThemeRow.swift 3 | // 4 | // Created by James Sedlacek on 2/21/25. 5 | // 6 | 7 | import Enumerations 8 | import SwiftUI 9 | 10 | @MainActor 11 | struct ThemeRow: View { 12 | let theme: Theme 13 | 14 | var body: some View { 15 | HStack { 16 | Label(.theme, symbol: .paintpalette) 17 | Spacer() 18 | Text(theme.name) 19 | .padding(4) 20 | .foregroundStyle(theme.accentColor) 21 | .background( 22 | theme.mainColor, 23 | in: .rect(cornerRadius: 4) 24 | ) 25 | } 26 | .accessibilityElement(children: .combine) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Library/Sources/Scrum/History/HistoryScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HistoryScreen.swift 3 | // 4 | // Created by James Sedlacek on 2/15/25. 5 | // 6 | 7 | import Extensions 8 | import Models 9 | import SwiftUI 10 | 11 | @MainActor 12 | struct HistoryScreen { 13 | private let history: History 14 | 15 | init(_ history: History) { 16 | self.history = history 17 | } 18 | } 19 | 20 | extension HistoryScreen: View { 21 | var body: some View { 22 | ScrollView { 23 | VStack(alignment: .leading) { 24 | Divider() 25 | .padding(.bottom) 26 | 27 | Text(.attendees) 28 | .font(.headline) 29 | 30 | Text(history.attendeeString) 31 | 32 | transcriptView 33 | } 34 | } 35 | .navigationTitle(dateText) 36 | .padding() 37 | } 38 | 39 | private var dateText: Text { 40 | Text(history.date, style: .date) 41 | } 42 | 43 | @ViewBuilder 44 | private var transcriptView: some View { 45 | if let transcript = history.transcript { 46 | Text(.transcript) 47 | .font(.headline) 48 | .padding(.top) 49 | 50 | Text(transcript) 51 | } 52 | } 53 | } 54 | 55 | #Preview { 56 | NavigationStack { 57 | HistoryScreen(.mock()) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Library/Sources/Scrum/Meeting/MeetingScreen+View.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MeetingScreen+View.swift 3 | // 4 | // Created by James Sedlacek on 2/17/25. 5 | // 6 | 7 | import SwiftUI 8 | 9 | extension MeetingScreen: View { 10 | var body: some View { 11 | VStack { 12 | headerSection 13 | timerSection 14 | footerSection 15 | } 16 | .task(priority: .background, startSpeechRecording) 17 | .onDisappear(perform: onDisappear) 18 | .onChange(of: activeSpeaker, onActiveSpeakerChange) 19 | .toolbarTitleDisplayMode(.inline) 20 | .frame(maxWidth: .infinity, maxHeight: .infinity) 21 | .foregroundStyle(scrum.theme.accentColor) 22 | .background( 23 | scrum.theme.mainColor, 24 | in: .rect(cornerRadius: 16) 25 | ) 26 | .padding() 27 | .alert( 28 | isPresented: isErrorPresented, 29 | error: errorToPresent, 30 | actions: errorActions, 31 | message: errorMessage 32 | ) 33 | } 34 | 35 | private var headerSection: some View { 36 | MeetingHeaderView( 37 | secondsElapsed: secondsElapsed, 38 | secondsRemaining: secondsRemaining, 39 | theme: scrum.theme 40 | ) 41 | } 42 | 43 | private var timerSection: some View { 44 | MeetingTimerView( 45 | activeSpeaker: activeSpeaker, 46 | speakers: scrum.attendees, 47 | theme: scrum.theme, 48 | isRecording: isRecording 49 | ) 50 | } 51 | 52 | private var footerSection: some View { 53 | MeetingFooterView( 54 | activeSpeaker: activeSpeaker, 55 | speakers: scrum.attendees, 56 | skipAction: skipAction 57 | ) 58 | } 59 | 60 | private func errorMessage(for error: LocalizedError) -> some View { 61 | Text(error.errorDescription ?? "Unknown error") 62 | } 63 | 64 | private func errorActions(for error: LocalizedError) -> some View { 65 | Button(.ok) {} 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Library/Sources/Scrum/Meeting/MeetingScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MeetingScreen.swift 3 | // 4 | // Created by James Sedlacek on 2/14/25. 5 | // 6 | 7 | import AudioService 8 | import Enumerations 9 | import Extensions 10 | import Models 11 | import Protocols 12 | import Resources 13 | import SpeechService 14 | import SwiftUI 15 | 16 | @MainActor 17 | struct MeetingScreen { 18 | @Environment(\.dismiss) private var dismiss 19 | @Environment(\.audioService) private var audioService 20 | @Environment(\.speechService) private var speechService 21 | @StoredData private var dailyScrums: [DailyScrum] 22 | @State var scrum: DailyScrum 23 | @State var activeSpeaker: DailyScrum.Attendee? 24 | @State var secondsElapsed: TimeInterval = 0 25 | @State var errorToPresent: SpeechServiceError? 26 | @State private var timerTask: Task? 27 | @State private var transcript: String = "" 28 | 29 | var isErrorPresented: Binding { 30 | .constant(errorToPresent != nil) 31 | } 32 | 33 | // Recording is active when timer is running, no errors, and meeting isn't complete 34 | var isRecording: Bool { 35 | timerTask != nil && errorToPresent == nil && !isMeetingComplete 36 | } 37 | 38 | // Meeting is complete when elapsed time reaches or exceeds total length 39 | private var isMeetingComplete: Bool { 40 | secondsElapsed >= scrum.length 41 | } 42 | 43 | // Calculates remaining time by subtracting elapsed from total length 44 | var secondsRemaining: TimeInterval { 45 | scrum.length - secondsElapsed 46 | } 47 | 48 | // Determines current speaker's position based on elapsed time 49 | private var speakerIndex: Int { 50 | Int(secondsElapsed / scrum.secondsPerSpeaker) 51 | } 52 | 53 | init(_ scrum: DailyScrum) { 54 | _scrum = .init(initialValue: scrum) 55 | _activeSpeaker = .init(initialValue: scrum.attendees.first) 56 | } 57 | 58 | // Creates async task that updates progress every second 59 | private func startTimer() { 60 | guard timerTask == nil else { return } 61 | 62 | timerTask = Task { 63 | while !Task.isCancelled { 64 | try? await Task.sleep(for: .seconds(1)) 65 | guard !Task.isCancelled else { break } 66 | updateProgress() 67 | } 68 | } 69 | } 70 | 71 | private func stopTimer() { 72 | timerTask?.cancel() 73 | timerTask = nil 74 | } 75 | 76 | // Manages meeting completion or updates time and speaker 77 | func updateProgress() { 78 | if isMeetingComplete { 79 | stopTimer() 80 | updateHistory() 81 | dismiss() 82 | } else { 83 | secondsElapsed += 1 84 | updateActiveSpeaker() 85 | } 86 | } 87 | 88 | // Updates active speaker based on elapsed time, prevents index overflow 89 | private func updateActiveSpeaker() { 90 | let newSpeakerIndex = min(speakerIndex, scrum.attendees.count - 1) 91 | guard newSpeakerIndex < scrum.attendees.count, newSpeakerIndex >= 0 else { return } 92 | let newSpeaker = scrum.attendees[newSpeakerIndex] 93 | 94 | // Only update and trigger sound if the speaker actually changes 95 | if activeSpeaker?.id != newSpeaker.id { 96 | activeSpeaker = newSpeaker 97 | } 98 | } 99 | 100 | @Sendable func startSpeechRecording() async { 101 | do throws(SpeechServiceError) { 102 | try await speechService.requestPermissions() 103 | startTimer() 104 | let stream = try speechService.startRecording() 105 | do { 106 | for try await partialResult in stream { 107 | self.transcript = partialResult 108 | } 109 | } catch { 110 | throw .streamFailed(error) 111 | } 112 | } catch { 113 | errorToPresent = error 114 | } 115 | } 116 | 117 | private func updateHistory() { 118 | let history = History( 119 | attendees: scrum.attendees, 120 | length: scrum.length, 121 | transcript: transcript 122 | ) 123 | let updatedHistory: [History] = [history] + scrum.history 124 | scrum.history = updatedHistory 125 | _dailyScrums.upsert(scrum) 126 | } 127 | 128 | // Advances to next speaker by calculating and adding required time 129 | func skipAction() { 130 | let secondsToAdd = TimeInterval(speakerIndex + 1) * scrum.secondsPerSpeaker - secondsElapsed 131 | secondsElapsed += secondsToAdd 132 | updateActiveSpeaker() 133 | } 134 | 135 | func onActiveSpeakerChange() { 136 | do throws(AudioServiceError) { 137 | try audioService.play(.ding) 138 | } catch { 139 | print("❌ Audio error: \(error.errorDescription ?? "")") 140 | } 141 | } 142 | } 143 | 144 | extension MeetingScreen: ViewLifecycle { 145 | func onDisappear() { 146 | stopTimer() 147 | speechService.stopRecording() 148 | updateHistory() 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /Library/Sources/Scrum/Meeting/Subviews/MeetingFooterView.swift: -------------------------------------------------------------------------------- 1 | import Models 2 | import SwiftUI 3 | 4 | @MainActor 5 | struct MeetingFooterView { 6 | private let activeSpeaker: DailyScrum.Attendee? 7 | private let speakers: [DailyScrum.Attendee] 8 | private let skipAction: () -> Void 9 | 10 | init( 11 | activeSpeaker: DailyScrum.Attendee?, 12 | speakers: [DailyScrum.Attendee], 13 | skipAction: @escaping () -> Void = {} 14 | ) { 15 | self.activeSpeaker = activeSpeaker 16 | self.speakers = speakers 17 | self.skipAction = skipAction 18 | } 19 | 20 | private var speakerNumber: Int? { 21 | guard let activeSpeaker, 22 | let index = speakers.firstIndex(of: activeSpeaker) 23 | else { return nil } 24 | return index + 1 25 | } 26 | 27 | private var isLastSpeaker: Bool { 28 | guard let activeSpeaker, 29 | let index = speakers.firstIndex(of: activeSpeaker) 30 | else { return false } 31 | return index == speakers.count - 1 32 | } 33 | 34 | private var footerText: LocalizedStringKey { 35 | guard let speakerNumber else { return .noSpeakers } 36 | return isLastSpeaker ? .lastSpeaker : .speakerCount(speakerNumber, speakers.count) 37 | } 38 | } 39 | 40 | extension MeetingFooterView: View { 41 | var body: some View { 42 | HStack { 43 | Text(footerText) 44 | 45 | if !isLastSpeaker { 46 | Spacer() 47 | skipButton 48 | } 49 | } 50 | .padding([.bottom, .horizontal]) 51 | } 52 | 53 | private var skipButton: some View { 54 | Button(action: skipAction) { 55 | Image(.forward) 56 | } 57 | .accessibilityLabel(.nextSpeaker) 58 | } 59 | } 60 | 61 | #Preview("No Speakers") { 62 | MeetingFooterView( 63 | activeSpeaker: nil, 64 | speakers: [] 65 | ) 66 | } 67 | 68 | #Preview("One Speaker") { 69 | let speaker = DailyScrum.Attendee(id: UUID(), name: "John") 70 | return MeetingFooterView( 71 | activeSpeaker: speaker, 72 | speakers: [speaker] 73 | ) 74 | } 75 | 76 | #Preview("Multiple Speakers") { 77 | let john = DailyScrum.Attendee(id: UUID(), name: "John") 78 | let jane = DailyScrum.Attendee(id: UUID(), name: "Jane") 79 | let bob = DailyScrum.Attendee(id: UUID(), name: "Bob") 80 | return MeetingFooterView( 81 | activeSpeaker: john, 82 | speakers: [john, jane, bob] 83 | ) 84 | } 85 | 86 | #Preview("Last Speaker") { 87 | let john = DailyScrum.Attendee(id: UUID(), name: "John") 88 | let jane = DailyScrum.Attendee(id: UUID(), name: "Jane") 89 | let bob = DailyScrum.Attendee(id: UUID(), name: "Bob") 90 | return MeetingFooterView( 91 | activeSpeaker: bob, 92 | speakers: [john, jane, bob] 93 | ) 94 | } 95 | -------------------------------------------------------------------------------- /Library/Sources/Scrum/Meeting/Subviews/MeetingHeaderView.swift: -------------------------------------------------------------------------------- 1 | import Enumerations 2 | import Resources 3 | import SwiftUI 4 | import ViewComponents 5 | 6 | @MainActor 7 | struct MeetingHeaderView { 8 | private let secondsElapsed: TimeInterval 9 | private let secondsRemaining: TimeInterval 10 | private let theme: Theme 11 | 12 | init( 13 | secondsElapsed: TimeInterval, 14 | secondsRemaining: TimeInterval, 15 | theme: Theme 16 | ) { 17 | self.secondsElapsed = secondsElapsed 18 | self.secondsRemaining = secondsRemaining 19 | self.theme = theme 20 | } 21 | 22 | private var totalSeconds: TimeInterval { 23 | secondsElapsed + secondsRemaining 24 | } 25 | 26 | private var progress: TimeInterval { 27 | guard totalSeconds > 0 else { return 1 } 28 | return secondsElapsed / totalSeconds 29 | } 30 | 31 | private var minutesRemaining: Int { 32 | secondsRemaining.minutes 33 | } 34 | } 35 | 36 | extension MeetingHeaderView: View { 37 | var body: some View { 38 | VStack { 39 | progressView 40 | timeLabelsRow 41 | } 42 | .accessibilityElement(children: .ignore) 43 | .accessibilityLabel(.timeRemaining) 44 | .accessibilityValue(.timeRemaining(minutes: minutesRemaining)) 45 | .padding([.top, .horizontal]) 46 | } 47 | 48 | private var progressView: some View { 49 | ProgressView(value: progress) 50 | .tint(theme.mainColor) 51 | .progressViewStyle(.rounded(background: theme.accentColor)) 52 | } 53 | 54 | private var timeLabelsRow: some View { 55 | HStack { 56 | elapsedTimeColumn 57 | Spacer() 58 | remainingTimeColumn 59 | } 60 | } 61 | 62 | private var elapsedTimeColumn: some View { 63 | VStack(alignment: .leading) { 64 | Text(.secondsElapsed) 65 | .font(.caption) 66 | Label(secondsElapsed.formatted(), symbol: .hourglassBottom) 67 | } 68 | } 69 | 70 | private var remainingTimeColumn: some View { 71 | VStack(alignment: .trailing) { 72 | Text(.secondsRemaining) 73 | .font(.caption) 74 | Label(secondsRemaining.formatted(), symbol: .hourglassTop) 75 | .labelStyle(.trailingIcon) 76 | } 77 | } 78 | } 79 | 80 | #Preview { 81 | MeetingHeaderView( 82 | secondsElapsed: 60, 83 | secondsRemaining: 180, 84 | theme: .bubblegum 85 | ) 86 | } 87 | -------------------------------------------------------------------------------- /Library/Sources/Scrum/Meeting/Subviews/MeetingTimerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MeetingTimerView.swift 3 | // 4 | // Created by James Sedlacek on 2/17/25. 5 | // 6 | 7 | import Enumerations 8 | import Models 9 | import Resources 10 | import SwiftUI 11 | 12 | @MainActor 13 | struct MeetingTimerView { 14 | private let activeSpeaker: DailyScrum.Attendee? 15 | private let speakers: [DailyScrum.Attendee] 16 | private let theme: Theme 17 | private let isRecording: Bool 18 | 19 | init( 20 | activeSpeaker: DailyScrum.Attendee?, 21 | speakers: [DailyScrum.Attendee], 22 | theme: Theme, 23 | isRecording: Bool 24 | ) { 25 | self.activeSpeaker = activeSpeaker 26 | self.speakers = speakers 27 | self.theme = theme 28 | self.isRecording = isRecording 29 | } 30 | 31 | private var activeSpeakerName: LocalizedStringKey { 32 | guard let name = activeSpeaker?.name else { 33 | return .someone 34 | } 35 | return .init(name) 36 | } 37 | 38 | private var symbol: Image.SFSymbol { 39 | isRecording ? .micActive : .micInactive 40 | } 41 | 42 | private var symbolAccessibilityLabel: LocalizedStringKey { 43 | isRecording ? .withTranscription : .withoutTranscription 44 | } 45 | } 46 | 47 | extension MeetingTimerView: View { 48 | var body: some View { 49 | Circle() 50 | .strokeBorder(lineWidth: 24) 51 | .overlay(content: timerContent) 52 | .overlay(content: speakerArcs) 53 | .padding(.horizontal) 54 | } 55 | 56 | private func timerContent() -> some View { 57 | VStack { 58 | Text(activeSpeakerName) 59 | .font(.title) 60 | Text(.isSpeaking) 61 | Image(symbol) 62 | .font(.title) 63 | .padding(.top) 64 | .accessibilityLabel(symbolAccessibilityLabel) 65 | } 66 | .accessibilityElement(children: .combine) 67 | .foregroundStyle(theme.accentColor) 68 | } 69 | 70 | private func speakerArcs() -> some View { 71 | ForEach(speakers) { speaker in 72 | if speaker != activeSpeaker, 73 | let index = speakers.firstIndex(of: speaker) { 74 | SpeakerArc( 75 | speakerIndex: index, 76 | totalSpeakers: speakers.count 77 | ) 78 | .rotation(Angle(degrees: -90)) 79 | .stroke(theme.mainColor, lineWidth: 12) 80 | } 81 | } 82 | } 83 | } 84 | 85 | #Preview("Active Speaker with Recording") { 86 | let john = DailyScrum.Attendee(id: UUID(), name: "John") 87 | let jane = DailyScrum.Attendee(id: UUID(), name: "Jane") 88 | let bob = DailyScrum.Attendee(id: UUID(), name: "Bob") 89 | return MeetingTimerView( 90 | activeSpeaker: john, 91 | speakers: [john, jane, bob], 92 | theme: .yellow, 93 | isRecording: true 94 | ) 95 | } 96 | 97 | #Preview("No Active Speaker") { 98 | let john = DailyScrum.Attendee(id: UUID(), name: "John") 99 | let jane = DailyScrum.Attendee(id: UUID(), name: "Jane") 100 | return MeetingTimerView( 101 | activeSpeaker: nil, 102 | speakers: [john, jane], 103 | theme: .yellow, 104 | isRecording: false 105 | ) 106 | } 107 | -------------------------------------------------------------------------------- /Library/Sources/Scrum/Meeting/Subviews/SpeakerArc.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SpeakerArc: Shape { 4 | let speakerIndex: Int 5 | let totalSpeakers: Int 6 | 7 | private var degreesPerSpeaker: Double { 8 | 360.0 / Double(totalSpeakers) 9 | } 10 | private var startAngle: Angle { 11 | Angle(degrees: degreesPerSpeaker * Double(speakerIndex) + 1.0) 12 | } 13 | private var endAngle: Angle { 14 | Angle(degrees: startAngle.degrees + degreesPerSpeaker - 1.0) 15 | } 16 | 17 | func path(in rect: CGRect) -> Path { 18 | let diameter = min(rect.size.width, rect.size.height) - 24.0 19 | let radius = diameter / 2.0 20 | let center = CGPoint(x: rect.midX, y: rect.midY) 21 | return Path { path in 22 | path.addArc( 23 | center: center, 24 | radius: radius, 25 | startAngle: startAngle, 26 | endAngle: endAngle, 27 | clockwise: false 28 | ) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Library/Sources/Scrum/ScrumRoute.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScrumRoute.swift 3 | // 4 | // Created by James Sedlacek on 2/14/25. 5 | // 6 | 7 | import Models 8 | import Protocols 9 | import SwiftUI 10 | 11 | enum ScrumRoute: Routable, Codable { 12 | case add 13 | case detail(DailyScrum) 14 | case edit(DailyScrum) 15 | case history(History) 16 | case meeting(DailyScrum) 17 | 18 | var body: some View { 19 | switch self { 20 | case .add: 21 | AddScreen() 22 | case .detail(let dailyScrum): 23 | DetailScreen(dailyScrum) 24 | case .edit(let dailyScrum): 25 | EditScreen(dailyScrum) 26 | case .history(let history): 27 | HistoryScreen(history) 28 | case .meeting(let dailyScrum): 29 | MeetingScreen(dailyScrum) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Library/Sources/Scrum/ScrumScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScrumScreen.swift 3 | // 4 | // Created by James Sedlacek on 2/14/25. 5 | // 6 | 7 | import Models 8 | import Protocols 9 | import Resources 10 | import SwiftUI 11 | import ViewComponents 12 | 13 | @MainActor 14 | public struct ScrumScreen { 15 | @DataStore private var scrumPath: [ScrumRoute] = [] 16 | @State private var routeToPresent: ScrumRoute? 17 | 18 | public init() {} 19 | 20 | private func addAction() { 21 | routeToPresent = .add 22 | } 23 | 24 | private func cardTapAction(_ scrum: DailyScrum) { 25 | scrumPath.append(.detail(scrum)) 26 | } 27 | } 28 | 29 | extension ScrumScreen: View { 30 | public var body: some View { 31 | NavigationStack(path: $scrumPath) { 32 | ScrumList(cardTapAction: cardTapAction) 33 | .navigationTitle(.dailyScrums) 34 | .navigationDestination( 35 | for: ScrumRoute.self, 36 | destination: ScrumRoute.destination 37 | ) 38 | .sheet( 39 | item: $routeToPresent, 40 | content: ScrumRoute.destination 41 | ) 42 | .toolbar(content: toolbarContent) 43 | } 44 | .environment(_scrumPath.storage) 45 | } 46 | } 47 | 48 | extension ScrumScreen: ToolbarView { 49 | public func toolbarContent() -> some ToolbarContent { 50 | ToolbarIconButton(symbol: .plus, perform: addAction) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Library/Sources/Scrum/Subviews/ScrumCard.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScrumCard.swift 3 | // 4 | // Created by James Sedlacek on 2/14/25. 5 | // 6 | 7 | import Extensions 8 | import Models 9 | import Resources 10 | import SwiftUI 11 | import ViewComponents 12 | 13 | @MainActor 14 | struct ScrumCard: View { 15 | private let scrum: DailyScrum 16 | 17 | init(_ scrum: DailyScrum) { 18 | self.scrum = scrum 19 | } 20 | 21 | var body: some View { 22 | VStack(alignment: .leading) { 23 | titleView 24 | Spacer() 25 | footerView 26 | } 27 | .padding() 28 | .foregroundStyle(scrum.theme.accentColor) 29 | .accessibilityIdentifier("ScrumCard_\(scrum.id)") 30 | } 31 | 32 | private var titleView: some View { 33 | Text(scrum.title) 34 | .accessibilityAddTraits(.isHeader) 35 | .font(.headline) 36 | } 37 | 38 | private var footerView: some View { 39 | HStack { 40 | attendeeLabel 41 | Spacer() 42 | lengthLabel 43 | } 44 | .font(.caption) 45 | } 46 | 47 | private var attendeeLabel: some View { 48 | Label("\(scrum.attendees.count)", symbol: .person3) 49 | .accessibilityLabel(.attendeeCount(scrum.attendees.count)) 50 | } 51 | 52 | private var lengthLabel: some View { 53 | Label("\(scrum.length.minutes)", symbol: .clock) 54 | .accessibilityLabel(.meetingLength(scrum.length.minutes)) 55 | .labelStyle(.trailingIcon) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Library/Sources/Scrum/Subviews/ScrumList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScrumList.swift 3 | // 4 | // Created by James Sedlacek on 2/21/25. 5 | // 6 | 7 | import FileService 8 | import Models 9 | import Protocols 10 | import Resources 11 | import SwiftUI 12 | import ViewComponents 13 | 14 | @MainActor 15 | struct ScrumList { 16 | @Environment(\.fileService) private var fileService 17 | @StoredData private var dailyScrums: [DailyScrum] 18 | private let cardTapAction: (DailyScrum) -> Void 19 | 20 | init(cardTapAction: @escaping (DailyScrum) -> Void) { 21 | self.cardTapAction = cardTapAction 22 | } 23 | 24 | private func loadData() { 25 | do throws(FileServiceError) { 26 | dailyScrums = try fileService.load(forKey: .dailyScrumsKey) 27 | } catch { 28 | if let description = error.errorDescription { 29 | print(description) 30 | } 31 | } 32 | } 33 | } 34 | 35 | extension ScrumList: View { 36 | var body: some View { 37 | List(dailyScrums, rowContent: scrumRow) 38 | .refreshable(action: loadData) 39 | .onAppear(perform: loadData) 40 | } 41 | 42 | private func scrumRow(_ scrum: DailyScrum) -> some View { 43 | ScrumCard(scrum) 44 | .listRowBackground(scrum.theme.mainColor) 45 | .contentShape(.rect) 46 | .onTapGesture { 47 | cardTapAction(scrum) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Models/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Models/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Models", 8 | defaultLocalization: "en", 9 | platforms: [.iOS(.v17), .macOS(.v14)], 10 | products: PackageProduct.allCases.map(\.description), 11 | targets: [ 12 | InternalTarget.allCases.map(\.target), 13 | InternalTestTarget.allCases.map(\.target) 14 | ].flatMap { $0 } 15 | ) 16 | 17 | // MARK: PackageProduct 18 | 19 | private enum PackageProduct: CaseIterable { 20 | case models 21 | 22 | var name: String { 23 | switch self { 24 | case .models: "Models" 25 | } 26 | } 27 | 28 | var targets: [InternalTarget] { 29 | switch self { 30 | case .models: 31 | InternalTarget.allCases 32 | } 33 | } 34 | 35 | var description: PackageDescription.Product { 36 | switch self { 37 | case .models: 38 | .library( 39 | name: self.name, 40 | targets: self.targets.map(\.title) 41 | ) 42 | } 43 | } 44 | } 45 | 46 | // MARK: InternalTarget 47 | 48 | private enum InternalTarget: CaseIterable { 49 | case enumerations 50 | case extensions 51 | case models 52 | case protocols 53 | 54 | var title: String { 55 | switch self { 56 | case .enumerations: return "Enumerations" 57 | case .extensions: return "Extensions" 58 | case .models: return "Models" 59 | case .protocols: return "Protocols" 60 | } 61 | } 62 | 63 | var targetDependency: Target.Dependency { 64 | .target(name: title) 65 | } 66 | 67 | var dependencies: [Target.Dependency] { 68 | switch self { 69 | case .enumerations: 70 | [ 71 | InternalTarget.protocols.targetDependency 72 | ] 73 | case .extensions: 74 | [ 75 | InternalTarget.models.targetDependency 76 | ] 77 | case .models: 78 | [ 79 | InternalTarget.enumerations.targetDependency 80 | ] 81 | default: [] 82 | } 83 | } 84 | 85 | var target: Target { 86 | .target( 87 | name: self.title, 88 | dependencies: self.dependencies 89 | ) 90 | } 91 | } 92 | 93 | // MARK: InternalTestTarget 94 | 95 | private enum InternalTestTarget: CaseIterable { 96 | case extensions 97 | 98 | var title: String { 99 | switch self { 100 | case .extensions: "ExtensionsTests" 101 | } 102 | } 103 | 104 | var dependencies: [Target.Dependency] { 105 | switch self { 106 | case .extensions: [InternalTarget.extensions.targetDependency] 107 | } 108 | } 109 | 110 | var target: Target { 111 | .testTarget( 112 | name: self.title, 113 | dependencies: self.dependencies 114 | ) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /Models/Sources/Enumerations/RuntimeEnvironment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RuntimeEnvironment.swift 3 | // 4 | // Created by James Sedlacek on 2/22/25. 5 | // 6 | 7 | import SwiftUI 8 | 9 | public enum RuntimeEnvironment: Equatable { 10 | case previews 11 | case simulator(String) 12 | case physicalDevice 13 | 14 | public static var current: RuntimeEnvironment { 15 | let environment = ProcessInfo.processInfo.environment 16 | 17 | if environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" { 18 | return .previews 19 | } 20 | if let simulatorName = environment["SIMULATOR_DEVICE_NAME"] { 21 | return .simulator(simulatorName) 22 | } 23 | return .physicalDevice 24 | } 25 | 26 | public var isPreview: Bool { self == .previews } 27 | public var isSimulator: Bool { 28 | if case .simulator = self { return true } 29 | return false 30 | } 31 | public var isPhysicalDevice: Bool { self == .physicalDevice } 32 | } 33 | 34 | extension RuntimeEnvironment: CustomStringConvertible { 35 | public var description: String { 36 | switch self { 37 | case .previews: 38 | return "Running in Xcode Previews" 39 | case .simulator(let name): 40 | return "Running in Simulator (\(name))" 41 | case .physicalDevice: 42 | return "Running on a Physical Device" 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Models/Sources/Enumerations/Theme.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Theme.swift 3 | // 4 | // Created by James Sedlacek on 2/14/25. 5 | // 6 | 7 | import SwiftUI 8 | 9 | public enum Theme: String, CaseIterable, Identifiable, Hashable, Codable { 10 | case bubblegum 11 | case buttercup 12 | case indigo 13 | case lavender 14 | case magenta 15 | case navy 16 | case orange 17 | case oxblood 18 | case periwinkle 19 | case poppy 20 | case purple 21 | case seafoam 22 | case sky 23 | case tan 24 | case teal 25 | case yellow 26 | 27 | public var accentColor: Color { 28 | switch self { 29 | case .bubblegum, .buttercup, .lavender, .orange, .periwinkle, .poppy, .seafoam, .sky, .tan, .teal, .yellow: 30 | return .black 31 | case .indigo, .magenta, .navy, .oxblood, .purple: 32 | return .white 33 | } 34 | } 35 | 36 | public var name: String { 37 | rawValue.capitalized 38 | } 39 | 40 | public var id: String { 41 | name 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Models/Sources/Extensions/DailyScrum+SecondsPerSpeaker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DailyScrum+SecondsPerSpeaker.swift 3 | // 4 | // Created by James Sedlacek on 2/22/25. 5 | // 6 | 7 | import Foundation 8 | import Models 9 | 10 | extension DailyScrum { 11 | // Divides total meeting time equally among all attendees 12 | public var secondsPerSpeaker: TimeInterval { 13 | length / TimeInterval(attendees.count) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Models/Sources/Extensions/History+AttendeeString.swift: -------------------------------------------------------------------------------- 1 | // 2 | // History+AttendeeString.swift 3 | // 4 | // Created by James Sedlacek on 2/18/25. 5 | // 6 | 7 | import Foundation 8 | import Models 9 | 10 | extension History { 11 | /// A formatted string containing all attendee names separated by commas and "and" 12 | /// 13 | /// Example: "John, Jane, and Bob" 14 | public var attendeeString: String { 15 | ListFormatter.localizedString( 16 | byJoining: attendees.map(\.name) 17 | ) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Models/Sources/Extensions/TimeInterval+Minutes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimeInterval+Minutes.swift 3 | // 4 | // Created by James Sedlacek on 2/22/25. 5 | // 6 | 7 | import Foundation 8 | 9 | extension TimeInterval { 10 | /// Converts time interval to minutes by dividing by 60 11 | /// 12 | /// Example: 13 | /// ```swift 14 | /// let interval: TimeInterval = 300 // 5 minutes in seconds 15 | /// print(interval.minutes) // Prints: 5 16 | /// ``` 17 | public var minutes: Int { 18 | Int(self / 60) 19 | } 20 | 21 | /// Creates a TimeInterval from a specified number of minutes 22 | /// 23 | /// Example: 24 | /// ```swift 25 | /// let fiveMinutes = TimeInterval.minutes(5) // Returns: 300 seconds 26 | /// ``` 27 | /// - Parameter count: The number of minutes 28 | /// - Returns: A TimeInterval representing the specified minutes in seconds 29 | public static func minutes(_ count: Int) -> TimeInterval { 30 | TimeInterval(count * 60) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Models/Sources/Models/DailyScrum.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DailyScrum.swift 3 | // 4 | // Created by James Sedlacek on 2/14/25. 5 | // 6 | 7 | import Enumerations 8 | import Foundation 9 | 10 | public struct DailyScrum: Identifiable, Codable { 11 | public let id: UUID 12 | public var title: String 13 | public var attendees: [Attendee] 14 | public var length: TimeInterval 15 | public var theme: Theme 16 | public var history: [History] 17 | 18 | public init( 19 | id: UUID = UUID(), 20 | title: String, 21 | attendees: [String], 22 | length: TimeInterval, 23 | theme: Theme, 24 | history: [History] = [] 25 | ) { 26 | self.id = id 27 | self.title = title 28 | self.attendees = attendees.map { Attendee(name: $0) } 29 | self.length = length 30 | self.theme = theme 31 | self.history = history 32 | } 33 | } 34 | 35 | extension DailyScrum: Hashable { 36 | public func hash(into hasher: inout Hasher) { 37 | hasher.combine(id) 38 | hasher.combine(title) 39 | hasher.combine(attendees) 40 | hasher.combine(length) 41 | hasher.combine(theme) 42 | hasher.combine(history) 43 | } 44 | 45 | public static func == (lhs: Self, rhs: Self) -> Bool { 46 | lhs.id == rhs.id && 47 | lhs.title == rhs.title && 48 | lhs.attendees == rhs.attendees && 49 | lhs.length == rhs.length && 50 | lhs.theme == rhs.theme && 51 | lhs.history == rhs.history 52 | } 53 | } 54 | 55 | extension DailyScrum { 56 | public struct Attendee: Identifiable, Hashable, Codable { 57 | public let id: UUID 58 | public var name: String 59 | 60 | public init(id: UUID = UUID(), name: String) { 61 | self.id = id 62 | self.name = name 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Models/Sources/Models/DataStorage/DataStorage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataStorage.swift 3 | // 4 | // Created by James Sedlacek on 12/27/24. 5 | // 6 | 7 | import Foundation 8 | 9 | /// A generic storage class that manages a collection of identifiable objects. 10 | /// - Supports CRUD operations (Create, Read, Update, Delete). 11 | /// - Thread-safe with @MainActor attribute. 12 | /// - Observable for SwiftUI integration. 13 | @MainActor 14 | @Observable 15 | public class DataStorage: @preconcurrency Equatable { 16 | /// The current collection of stored objects. 17 | /// - Note: This property is read-only externally but can be modified through the provided methods. 18 | public private(set) var objects: [T] 19 | 20 | /// Creates a new DataStorage instance. 21 | /// - Parameters: 22 | /// - initialObjects: The initial collection of objects to store. Defaults to empty array. 23 | public init(initialObjects: [T] = []) { 24 | self.objects = initialObjects 25 | } 26 | 27 | /// Replaces all objects in the storage with a new collection. 28 | /// - Parameter newObjects: The new collection of objects to store. 29 | public func replace(with newObjects: [T]) { 30 | objects = newObjects 31 | } 32 | 33 | /// Updates an existing object or inserts a new one into the storage. 34 | /// - Parameter object: The object to upsert. 35 | /// - Note: Objects are matched by their id property. 36 | public func upsert(_ object: T) { 37 | guard let index = objects.firstIndex(where: { $0.id == object.id }) else { 38 | // Object does not exist, append it. 39 | objects.append(object) 40 | return 41 | } 42 | // Object exists, replace it. 43 | objects[index] = object 44 | } 45 | 46 | /// Updates existing objects or inserts new ones into the storage. 47 | /// - Parameter objects: The array of objects to upsert. 48 | /// - Note: Objects are matched by their id property. 49 | public func upsert(_ objects: [T]) { 50 | objects.forEach { upsert($0) } 51 | } 52 | 53 | /// Deletes an object from the storage if it exists. 54 | /// - Parameter object: The object to delete. 55 | /// - Note: Objects are matched by their id property. 56 | public func delete(_ object: T) { 57 | guard let index = objects.firstIndex(where: { $0.id == object.id }) else { 58 | // Object does not exist, nothing to delete. 59 | return 60 | } 61 | // Object exists, remove it. 62 | objects.remove(at: index) 63 | } 64 | 65 | /// Removes all objects from the storage. 66 | public func deleteAll() { 67 | objects.removeAll() 68 | } 69 | 70 | /// Compares two DataStorage instances for equality. 71 | /// - Returns: True if both instances contain the same objects. 72 | public static func == ( 73 | lhs: DataStorage, 74 | rhs: DataStorage 75 | ) -> Bool { 76 | return lhs.objects == rhs.objects 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Models/Sources/Models/DataStorage/DataStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataStore.swift 3 | // 4 | // Created by James Sedlacek on 2/20/25. 5 | // 6 | 7 | import SwiftUI 8 | 9 | /// A property wrapper that provides a convenient way to store and manage collections of identifiable objects. 10 | /// - Manages a DataStorage instance internally. 11 | /// - Provides direct access to the stored objects array. 12 | /// - Supports SwiftUI binding through projected value. 13 | /// - Thread-safe with @MainActor attribute. 14 | @MainActor 15 | @propertyWrapper 16 | public struct DataStore { 17 | /// The underlying storage instance that manages the data. 18 | @State public private(set) var storage: DataStorage 19 | 20 | /// The wrapped value provides direct access to the stored objects array. 21 | /// - Get: Returns the current collection of objects. 22 | /// - Set: Replaces the entire collection with new objects. 23 | public var wrappedValue: [T] { 24 | get { storage.objects } 25 | nonmutating set { storage.replace(with: newValue) } 26 | } 27 | 28 | /// The projected value provides a SwiftUI binding to the objects array. 29 | /// - Returns: A binding that can be used in SwiftUI views. 30 | public var projectedValue: Binding<[T]> { 31 | .init( 32 | get: { wrappedValue }, 33 | set: { wrappedValue = $0 } 34 | ) 35 | } 36 | 37 | /// Creates a new DataStore instance. 38 | /// - Parameters: 39 | /// - wrappedValue: The initial collection of objects. Defaults to empty array. 40 | public init(wrappedValue: [T] = []) { 41 | _storage = State( 42 | initialValue: DataStorage(initialObjects: wrappedValue) 43 | ) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Models/Sources/Models/DataStorage/StoredData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoredData.swift 3 | // 4 | // Created by James Sedlacek on 2/20/25. 5 | // 6 | 7 | import SwiftUI 8 | 9 | /// A property wrapper that provides access to shared DataStorage through SwiftUI's environment. 10 | /// - Conforms to DynamicProperty for SwiftUI integration. 11 | /// - Provides direct access to stored objects. 12 | /// - Supports SwiftUI binding through projected value. 13 | /// - Thread-safe with @MainActor attribute. 14 | @MainActor 15 | @propertyWrapper 16 | public struct StoredData: DynamicProperty { 17 | /// The DataStorage instance retrieved from the environment. 18 | @Environment(DataStorage.self) private var storage 19 | 20 | /// The wrapped value provides direct access to the stored objects array. 21 | /// - Get: Returns the current collection of objects. 22 | /// - Set: Replaces the entire collection with new objects. 23 | public var wrappedValue: [T] { 24 | get { storage.objects } 25 | nonmutating set { storage.replace(with: newValue) } 26 | } 27 | 28 | /// The projected value provides a SwiftUI binding to the objects array. 29 | /// - Returns: A binding that can be used in SwiftUI views. 30 | public var projectedValue: Binding<[T]> { 31 | .init( 32 | get: { wrappedValue }, 33 | set: { wrappedValue = $0 } 34 | ) 35 | } 36 | 37 | /// Creates a new StoredData instance. 38 | public init() {} 39 | 40 | /// Convenience method to update or insert a single object. 41 | /// - Parameter object: The object to upsert into storage. 42 | public func upsert(_ object: T) { 43 | storage.upsert(object) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Models/Sources/Models/History.swift: -------------------------------------------------------------------------------- 1 | // 2 | // History.swift 3 | // 4 | // Created by James Sedlacek on 2/14/25. 5 | // 6 | 7 | import Foundation 8 | 9 | public struct History: Identifiable, Hashable, Codable { 10 | public let id: UUID 11 | public let date: Date 12 | public var attendees: [DailyScrum.Attendee] 13 | public var length: TimeInterval 14 | public var transcript: String? 15 | 16 | public init( 17 | id: UUID = .init(), 18 | date: Date = .now, 19 | attendees: [DailyScrum.Attendee], 20 | length: TimeInterval = 300, // 5 minutes 21 | transcript: String? = nil 22 | ) { 23 | self.id = id 24 | self.date = date 25 | self.attendees = attendees 26 | self.length = length 27 | self.transcript = transcript 28 | } 29 | } 30 | 31 | public extension History { 32 | static func mock() -> History { 33 | .init( 34 | attendees: [ 35 | .init(name: "Jon"), 36 | .init(name: "Darla") 37 | ], 38 | length: 300, 39 | transcript: 40 | """ 41 | Darla, would you like to start today? Sure, yesterday I reviewed Luis' PR 42 | and met with the design team to finalize the UI... 43 | """ 44 | ) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Models/Sources/Protocols/Routable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Routable.swift 3 | // 4 | // Created by James Sedlacek on 2/7/25. 5 | // 6 | 7 | import SwiftUI 8 | 9 | /// Routable provides a standardized way to handle navigation and view creation in SwiftUI. 10 | /// 11 | /// This protocol combines: 12 | /// - Hashable: For unique identification of routes 13 | /// - View: For rendering the destination 14 | /// - Identifiable: For collection management 15 | /// 16 | /// Usage: 17 | /// ```swift 18 | /// enum AppRoute: Routable { 19 | /// case detail(item: Item) 20 | /// case settings 21 | /// 22 | /// var body: some View { 23 | /// switch self { 24 | /// case .detail(let item): 25 | /// DetailView(item: item) 26 | /// case .settings: 27 | /// SettingsView() 28 | /// } 29 | /// } 30 | /// } 31 | /// ``` 32 | public protocol Routable: Hashable, View, Identifiable { 33 | /// The route itself serves as its identifier 34 | var id: Self { get } 35 | 36 | /// Creates the destination view for a given route 37 | /// - Parameter route: The route to create a view for 38 | /// - Returns: The view associated with the route 39 | static func destination(for route: Self) -> Body 40 | } 41 | 42 | public extension Routable { 43 | nonisolated var id: Self { self } 44 | 45 | static func destination(for route: Self) -> Body { 46 | route.body 47 | } 48 | 49 | nonisolated func hash(into hasher: inout Hasher) { 50 | hasher.combine(self) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Models/Sources/Protocols/ToolbarView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToolbarView.swift 3 | // 4 | // 5 | // Created by James Sedlacek on 5/24/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// ToolbarView provides a standardized way to define toolbar content in SwiftUI views. 11 | /// 12 | /// This protocol simplifies toolbar creation by requiring a single method that returns 13 | /// toolbar content. It enforces a consistent pattern for toolbar implementation across views. 14 | /// 15 | /// Usage: 16 | /// ```swift 17 | /// struct YourView: View, ToolbarView { 18 | /// func toolbarContent() -> some ToolbarContent { 19 | /// ToolbarItem(placement: .primaryAction) { 20 | /// Button("Add") { /* action */ } 21 | /// } 22 | /// } 23 | /// } 24 | /// ``` 25 | public protocol ToolbarView { 26 | /// The type of toolbar content this view provides 27 | associatedtype Content: ToolbarContent 28 | 29 | /// Returns the toolbar content for this view 30 | /// - Returns: A toolbar content builder that defines the toolbar items 31 | @MainActor 32 | @ToolbarContentBuilder 33 | func toolbarContent() -> Content 34 | } 35 | -------------------------------------------------------------------------------- /Models/Sources/Protocols/ViewLifecycle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewLifecycle.swift 3 | // 4 | // Created by James Sedlacek on 11/8/24. 5 | // 6 | 7 | /// ViewLifecycle provides a standardized way to handle view lifecycle events. 8 | /// 9 | /// This protocol defines three main lifecycle events: 10 | /// - `onTask`: Called when an async task begins 11 | /// - `onAppear`: Called when the view appears 12 | /// - `onDisappear`: Called when the view disappears 13 | /// 14 | /// Usage: 15 | /// ```swift 16 | /// struct YourView: View, ViewLifecycle { 17 | /// func onAppear() { 18 | /// // Setup when view appears 19 | /// } 20 | /// 21 | /// func onDisappear() { 22 | /// // Cleanup when view disappears 23 | /// } 24 | /// } 25 | /// ``` 26 | @MainActor 27 | public protocol ViewLifecycle { 28 | /// Called when an async task begins 29 | /// - Note: This is useful for initiating async operations when the view loads 30 | func onTask() async 31 | 32 | /// Called when the view appears in the interface 33 | /// - Note: Use this for setup code that needs to run when the view becomes visible 34 | func onAppear() 35 | 36 | /// Called when the view disappears from the interface 37 | /// - Note: Use this for cleanup code that needs to run when the view is no longer visible 38 | func onDisappear() 39 | } 40 | 41 | extension ViewLifecycle { 42 | public func onTask() async {} 43 | public func onAppear() {} 44 | public func onDisappear() {} 45 | } 46 | -------------------------------------------------------------------------------- /Models/Tests/ExtensionsTests/DailyScrumTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DailyScrumTests.swift 3 | // 4 | // Created by James Sedlacek on 2/22/25. 5 | // 6 | 7 | import Foundation 8 | import Testing 9 | 10 | @testable import Extensions 11 | @testable import Models 12 | 13 | struct DailyScrumTests { 14 | @Test func secondsPerSpeakerWithZeroAttendees() async throws { 15 | let scrum = DailyScrum( 16 | title: "Test", 17 | attendees: [], 18 | length: .minutes(10), 19 | theme: .seafoam 20 | ) 21 | #expect(scrum.secondsPerSpeaker.isInfinite) 22 | } 23 | 24 | @Test func secondsPerSpeakerWithOneAttendee() async throws { 25 | let scrum = DailyScrum( 26 | title: "Test", 27 | attendees: ["John"], 28 | length: .minutes(10), 29 | theme: .seafoam 30 | ) 31 | #expect(scrum.secondsPerSpeaker == .minutes(10)) 32 | } 33 | 34 | @Test func secondsPerSpeakerWithMultipleAttendees() async throws { 35 | let scrum = DailyScrum( 36 | title: "Test", 37 | attendees: ["John", "Jane", "Bob"], 38 | length: .minutes(15), 39 | theme: .seafoam 40 | ) 41 | #expect(scrum.secondsPerSpeaker == .minutes(5)) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Models/Tests/ExtensionsTests/HistoryTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Testing 3 | 4 | @testable import Extensions 5 | @testable import Models 6 | 7 | struct HistoryTests { 8 | @Test func testEmptyAttendeesList() async throws { 9 | let history = History(attendees: []) 10 | #expect(history.attendeeString == "") 11 | } 12 | 13 | @Test func testSingleAttendee() async throws { 14 | let history = History(attendees: [.init(name: "John")]) 15 | #expect(history.attendeeString == "John") 16 | } 17 | 18 | @Test func testTwoAttendees() async throws { 19 | let history = History(attendees: [ 20 | .init(name: "John"), 21 | .init(name: "Jane") 22 | ]) 23 | #expect(history.attendeeString == "John and Jane") 24 | } 25 | 26 | @Test func testThreeAttendees() async throws { 27 | let history = History(attendees: [ 28 | .init(name: "John"), 29 | .init(name: "Jane"), 30 | .init(name: "Bob") 31 | ]) 32 | #expect(history.attendeeString == "John, Jane, and Bob") 33 | } 34 | 35 | @Test func testMockHistory() async throws { 36 | let history = History.mock() 37 | #expect(history.attendeeString == "Jon and Darla") 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Scrumdinger](https://github.com/user-attachments/assets/a245d11d-c260-42a8-8286-785ff491c68c) 2 | 3 | # What is this? 4 | Scrumdinger is an iOS app designed to help teams manage their daily scrum meetings effectively.
5 | In agile software development, a daily scrum is a short, time-boxed meeting where team members discuss their progress and plans.
6 | 7 | Here's how it works: 8 | 1. **Create a Scrum**: Set up a meeting with: 9 | - A custom title 10 | - Meeting length (5-30 minutes) 11 | - List of attendees 12 | - Visual theme for easy identification 13 | 14 | 2. **Run the Meeting**: 15 | - Start a timed session 16 | - The app automatically divides time equally among attendees 17 | - Visual progress indicators show overall meeting and current speaker progress 18 | - Optional audio recording with real-time transcription 19 | - Skip to next speaker when needed 20 | 21 | 3. **Review History**: 22 | - After each meeting, a history entry is created 23 | - View past meetings with full transcripts 24 | - Track who was in attendance 25 | 26 | The app ensures meetings stay focused and on-time while maintaining a record of discussions for future reference. 27 | 28 | # Why did I build this? 29 | While this project started from Apple's tutorial, I saw it as an opportunity to showcase and implement modern iOS development practices. My focus was on: 30 | 31 | 1. **Modular Architecture** 32 | - Separating concerns into distinct packages 33 | - Creating reusable components 34 | 35 | 2. **Comprehensive Testing** 36 | - Automation testing via UI tests 37 | - Unit testing via Swift Testing 38 | 39 | 3. **Code Quality** 40 | - Following Swift best practices 41 | - Maintaining clear documentation 42 | 43 | The goal was to transform a tutorial project into a production-ready application that demonstrates professional iOS development standards. 44 | 45 | ## Requirements 46 | - iOS 17.0+ 47 | - Xcode 15.0+ 48 | - Swift 6.0+ 49 | 50 | ## Installation 51 | 1. Clone the repository 52 | 2. Open `Scrumdinger.xcodeproj` 53 | 3. Make sure you have SwiftLint installed on your machine: 54 | ```bash 55 | brew install swiftlint 56 | ``` 57 | 4. Install the Git pre-commit hook to enable automatic lint checks: 58 | ```bash 59 | sh scripts/install-hooks.sh 60 | ``` 61 | 5. Build and run the project 62 | 63 | # Features 64 | 1. **Daily Scrum Management:** 65 | - Create new scrums with title, length (5-30 minutes), and theme 66 | - Add and remove attendees 67 | - Edit existing scrums 68 | 69 | 2. **Meeting Features:** 70 | - Start timed meetings 71 | - Automatic time division between attendees 72 | - Visual progress tracking (ring for overall time, bar for current speaker) 73 | - Skip to next speaker 74 | - Audio recording and transcription 75 | - Ding sound that plays when it's the next attendee's turn to speak 76 | 77 | 3. **History and Review:** 78 | - Automatic history creation after meetings 79 | - View past meetings with date stamps 80 | - View attendee list for past meetings 81 | - Access meeting transcripts 82 | 83 | 84 | # Contributions 85 | We welcome contributions focused on: 86 | 87 | 1. **Architecture Improvements** 88 | - Code organization 89 | - Design patterns 90 | - Module structure 91 | - Dependency management 92 | 93 | 2. **Documentation** 94 | - Code comments 95 | - README updates 96 | - API documentation 97 | - Usage examples 98 | 99 | 3. **Testing** 100 | - Additional UI test coverage 101 | - Test refactoring 102 | - Test documentation 103 | - Performance testing 104 | 105 | Please note that we are not accepting new feature additions at this time. 106 | 107 | # Credits 108 | Based on Apple's [iOS App Dev Training](https://developer.apple.com/tutorials/app-dev-training/getting-started-with-scrumdinger)
109 | Inspired by the [SyncUps](https://github.com/pointfreeco/syncups) project by Point-Free Co 110 | -------------------------------------------------------------------------------- /Scrumdinger.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 77; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 868810AD2D5FC2E100446F79 /* Scrumdinger in Frameworks */ = {isa = PBXBuildFile; productRef = 868810AC2D5FC2E100446F79 /* Scrumdinger */; }; 11 | 868810AF2D5FC2E100446F79 /* Models in Frameworks */ = {isa = PBXBuildFile; productRef = 868810AE2D5FC2E100446F79 /* Models */; }; 12 | 868810B12D5FC2E100446F79 /* Services in Frameworks */ = {isa = PBXBuildFile; productRef = 868810B02D5FC2E100446F79 /* Services */; }; 13 | 868810B32D5FC2E100446F79 /* ViewComponents in Frameworks */ = {isa = PBXBuildFile; productRef = 868810B22D5FC2E100446F79 /* ViewComponents */; }; 14 | /* End PBXBuildFile section */ 15 | 16 | /* Begin PBXContainerItemProxy section */ 17 | 868810D12D6950F700446F79 /* PBXContainerItemProxy */ = { 18 | isa = PBXContainerItemProxy; 19 | containerPortal = 868810882D5FB60800446F79 /* Project object */; 20 | proxyType = 1; 21 | remoteGlobalIDString = 8688108F2D5FB60800446F79; 22 | remoteInfo = Scrumdinger; 23 | }; 24 | /* End PBXContainerItemProxy section */ 25 | 26 | /* Begin PBXFileReference section */ 27 | 868810902D5FB60800446F79 /* Scrumdinger.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Scrumdinger.app; sourceTree = BUILT_PRODUCTS_DIR; }; 28 | 868810A12D5FB66F00446F79 /* Library */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Library; sourceTree = ""; }; 29 | 868810A32D5FB99100446F79 /* Models */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Models; sourceTree = SOURCE_ROOT; }; 30 | 868810A52D5FB9CE00446F79 /* ViewComponents */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = ViewComponents; sourceTree = ""; }; 31 | 868810A72D5FB9EC00446F79 /* Services */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Services; sourceTree = ""; }; 32 | 868810CB2D6950F700446F79 /* ScrumdingerUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ScrumdingerUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 33 | /* End PBXFileReference section */ 34 | 35 | /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ 36 | 868810BE2D68179800446F79 /* Exceptions for "Scrumdinger" folder in "Scrumdinger" target */ = { 37 | isa = PBXFileSystemSynchronizedBuildFileExceptionSet; 38 | membershipExceptions = ( 39 | Info.plist, 40 | TestPlan.xctestplan, 41 | ); 42 | target = 8688108F2D5FB60800446F79 /* Scrumdinger */; 43 | }; 44 | /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ 45 | 46 | /* Begin PBXFileSystemSynchronizedRootGroup section */ 47 | 868810922D5FB60800446F79 /* Scrumdinger */ = { 48 | isa = PBXFileSystemSynchronizedRootGroup; 49 | exceptions = ( 50 | 868810BE2D68179800446F79 /* Exceptions for "Scrumdinger" folder in "Scrumdinger" target */, 51 | ); 52 | path = Scrumdinger; 53 | sourceTree = ""; 54 | }; 55 | 868810CC2D6950F700446F79 /* ScrumdingerUITests */ = { 56 | isa = PBXFileSystemSynchronizedRootGroup; 57 | path = ScrumdingerUITests; 58 | sourceTree = ""; 59 | }; 60 | /* End PBXFileSystemSynchronizedRootGroup section */ 61 | 62 | /* Begin PBXFrameworksBuildPhase section */ 63 | 8688108D2D5FB60800446F79 /* Frameworks */ = { 64 | isa = PBXFrameworksBuildPhase; 65 | buildActionMask = 2147483647; 66 | files = ( 67 | 868810B12D5FC2E100446F79 /* Services in Frameworks */, 68 | 868810AF2D5FC2E100446F79 /* Models in Frameworks */, 69 | 868810AD2D5FC2E100446F79 /* Scrumdinger in Frameworks */, 70 | 868810B32D5FC2E100446F79 /* ViewComponents in Frameworks */, 71 | ); 72 | runOnlyForDeploymentPostprocessing = 0; 73 | }; 74 | 868810C82D6950F700446F79 /* Frameworks */ = { 75 | isa = PBXFrameworksBuildPhase; 76 | buildActionMask = 2147483647; 77 | files = ( 78 | ); 79 | runOnlyForDeploymentPostprocessing = 0; 80 | }; 81 | /* End PBXFrameworksBuildPhase section */ 82 | 83 | /* Begin PBXGroup section */ 84 | 868810872D5FB60800446F79 = { 85 | isa = PBXGroup; 86 | children = ( 87 | 868810A12D5FB66F00446F79 /* Library */, 88 | 868810A32D5FB99100446F79 /* Models */, 89 | 868810A72D5FB9EC00446F79 /* Services */, 90 | 868810A52D5FB9CE00446F79 /* ViewComponents */, 91 | 868810922D5FB60800446F79 /* Scrumdinger */, 92 | 868810CC2D6950F700446F79 /* ScrumdingerUITests */, 93 | 868810AB2D5FC2E100446F79 /* Frameworks */, 94 | 868810912D5FB60800446F79 /* Products */, 95 | ); 96 | sourceTree = ""; 97 | }; 98 | 868810912D5FB60800446F79 /* Products */ = { 99 | isa = PBXGroup; 100 | children = ( 101 | 868810902D5FB60800446F79 /* Scrumdinger.app */, 102 | 868810CB2D6950F700446F79 /* ScrumdingerUITests.xctest */, 103 | ); 104 | name = Products; 105 | sourceTree = ""; 106 | }; 107 | 868810AB2D5FC2E100446F79 /* Frameworks */ = { 108 | isa = PBXGroup; 109 | children = ( 110 | ); 111 | name = Frameworks; 112 | sourceTree = ""; 113 | }; 114 | /* End PBXGroup section */ 115 | 116 | /* Begin PBXNativeTarget section */ 117 | 8688108F2D5FB60800446F79 /* Scrumdinger */ = { 118 | isa = PBXNativeTarget; 119 | buildConfigurationList = 8688109E2D5FB60A00446F79 /* Build configuration list for PBXNativeTarget "Scrumdinger" */; 120 | buildPhases = ( 121 | 8688108C2D5FB60800446F79 /* Sources */, 122 | 8688108D2D5FB60800446F79 /* Frameworks */, 123 | 8688108E2D5FB60800446F79 /* Resources */, 124 | FAC649CF2DD61C0B000FD608 /* ShellScript */, 125 | ); 126 | buildRules = ( 127 | ); 128 | dependencies = ( 129 | FAC649CE2DD61623000FD608 /* PBXTargetDependency */, 130 | ); 131 | fileSystemSynchronizedGroups = ( 132 | 868810922D5FB60800446F79 /* Scrumdinger */, 133 | ); 134 | name = Scrumdinger; 135 | packageProductDependencies = ( 136 | 868810AC2D5FC2E100446F79 /* Scrumdinger */, 137 | 868810AE2D5FC2E100446F79 /* Models */, 138 | 868810B02D5FC2E100446F79 /* Services */, 139 | 868810B22D5FC2E100446F79 /* ViewComponents */, 140 | ); 141 | productName = Scrumdinger; 142 | productReference = 868810902D5FB60800446F79 /* Scrumdinger.app */; 143 | productType = "com.apple.product-type.application"; 144 | }; 145 | 868810CA2D6950F700446F79 /* ScrumdingerUITests */ = { 146 | isa = PBXNativeTarget; 147 | buildConfigurationList = 868810D32D6950F700446F79 /* Build configuration list for PBXNativeTarget "ScrumdingerUITests" */; 148 | buildPhases = ( 149 | 868810C72D6950F700446F79 /* Sources */, 150 | 868810C82D6950F700446F79 /* Frameworks */, 151 | 868810C92D6950F700446F79 /* Resources */, 152 | ); 153 | buildRules = ( 154 | ); 155 | dependencies = ( 156 | 868810D22D6950F700446F79 /* PBXTargetDependency */, 157 | ); 158 | fileSystemSynchronizedGroups = ( 159 | 868810CC2D6950F700446F79 /* ScrumdingerUITests */, 160 | ); 161 | name = ScrumdingerUITests; 162 | packageProductDependencies = ( 163 | ); 164 | productName = ScrumdingerUITests; 165 | productReference = 868810CB2D6950F700446F79 /* ScrumdingerUITests.xctest */; 166 | productType = "com.apple.product-type.bundle.ui-testing"; 167 | }; 168 | /* End PBXNativeTarget section */ 169 | 170 | /* Begin PBXProject section */ 171 | 868810882D5FB60800446F79 /* Project object */ = { 172 | isa = PBXProject; 173 | attributes = { 174 | BuildIndependentTargetsInParallel = 1; 175 | LastSwiftUpdateCheck = 1620; 176 | LastUpgradeCheck = 1620; 177 | TargetAttributes = { 178 | 8688108F2D5FB60800446F79 = { 179 | CreatedOnToolsVersion = 16.2; 180 | }; 181 | 868810CA2D6950F700446F79 = { 182 | CreatedOnToolsVersion = 16.2; 183 | TestTargetID = 8688108F2D5FB60800446F79; 184 | }; 185 | }; 186 | }; 187 | buildConfigurationList = 8688108B2D5FB60800446F79 /* Build configuration list for PBXProject "Scrumdinger" */; 188 | developmentRegion = en; 189 | hasScannedForEncodings = 0; 190 | knownRegions = ( 191 | en, 192 | Base, 193 | ); 194 | mainGroup = 868810872D5FB60800446F79; 195 | minimizedProjectReferenceProxies = 1; 196 | packageReferences = ( 197 | FAC649CC2DD61438000FD608 /* XCRemoteSwiftPackageReference "SwiftLint" */, 198 | ); 199 | preferredProjectObjectVersion = 77; 200 | productRefGroup = 868810912D5FB60800446F79 /* Products */; 201 | projectDirPath = ""; 202 | projectRoot = ""; 203 | targets = ( 204 | 8688108F2D5FB60800446F79 /* Scrumdinger */, 205 | 868810CA2D6950F700446F79 /* ScrumdingerUITests */, 206 | ); 207 | }; 208 | /* End PBXProject section */ 209 | 210 | /* Begin PBXResourcesBuildPhase section */ 211 | 8688108E2D5FB60800446F79 /* Resources */ = { 212 | isa = PBXResourcesBuildPhase; 213 | buildActionMask = 2147483647; 214 | files = ( 215 | ); 216 | runOnlyForDeploymentPostprocessing = 0; 217 | }; 218 | 868810C92D6950F700446F79 /* Resources */ = { 219 | isa = PBXResourcesBuildPhase; 220 | buildActionMask = 2147483647; 221 | files = ( 222 | ); 223 | runOnlyForDeploymentPostprocessing = 0; 224 | }; 225 | /* End PBXResourcesBuildPhase section */ 226 | 227 | /* Begin PBXShellScriptBuildPhase section */ 228 | FAC649CF2DD61C0B000FD608 /* ShellScript */ = { 229 | isa = PBXShellScriptBuildPhase; 230 | buildActionMask = 2147483647; 231 | files = ( 232 | ); 233 | inputFileListPaths = ( 234 | ); 235 | inputPaths = ( 236 | ); 237 | outputFileListPaths = ( 238 | ); 239 | outputPaths = ( 240 | ); 241 | runOnlyForDeploymentPostprocessing = 0; 242 | shellPath = /bin/sh; 243 | shellScript = "if [[ \"$(uname -m)\" == arm64 ]]; then\n export PATH=\"/opt/homebrew/bin:$PATH\"\nfi\n\nif which swiftlint > /dev/null; then\n swiftlint\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; 244 | }; 245 | /* End PBXShellScriptBuildPhase section */ 246 | 247 | /* Begin PBXSourcesBuildPhase section */ 248 | 8688108C2D5FB60800446F79 /* Sources */ = { 249 | isa = PBXSourcesBuildPhase; 250 | buildActionMask = 2147483647; 251 | files = ( 252 | ); 253 | runOnlyForDeploymentPostprocessing = 0; 254 | }; 255 | 868810C72D6950F700446F79 /* Sources */ = { 256 | isa = PBXSourcesBuildPhase; 257 | buildActionMask = 2147483647; 258 | files = ( 259 | ); 260 | runOnlyForDeploymentPostprocessing = 0; 261 | }; 262 | /* End PBXSourcesBuildPhase section */ 263 | 264 | /* Begin PBXTargetDependency section */ 265 | 868810D22D6950F700446F79 /* PBXTargetDependency */ = { 266 | isa = PBXTargetDependency; 267 | target = 8688108F2D5FB60800446F79 /* Scrumdinger */; 268 | targetProxy = 868810D12D6950F700446F79 /* PBXContainerItemProxy */; 269 | }; 270 | FAC649CE2DD61623000FD608 /* PBXTargetDependency */ = { 271 | isa = PBXTargetDependency; 272 | productRef = FAC649CD2DD61623000FD608 /* SwiftLintBuildToolPlugin */; 273 | }; 274 | /* End PBXTargetDependency section */ 275 | 276 | /* Begin XCBuildConfiguration section */ 277 | 8688109C2D5FB60A00446F79 /* Debug */ = { 278 | isa = XCBuildConfiguration; 279 | buildSettings = { 280 | ALWAYS_SEARCH_USER_PATHS = NO; 281 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 282 | CLANG_ANALYZER_NONNULL = YES; 283 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 284 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 285 | CLANG_ENABLE_MODULES = YES; 286 | CLANG_ENABLE_OBJC_ARC = YES; 287 | CLANG_ENABLE_OBJC_WEAK = YES; 288 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 289 | CLANG_WARN_BOOL_CONVERSION = YES; 290 | CLANG_WARN_COMMA = YES; 291 | CLANG_WARN_CONSTANT_CONVERSION = YES; 292 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 293 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 294 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 295 | CLANG_WARN_EMPTY_BODY = YES; 296 | CLANG_WARN_ENUM_CONVERSION = YES; 297 | CLANG_WARN_INFINITE_RECURSION = YES; 298 | CLANG_WARN_INT_CONVERSION = YES; 299 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 300 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 301 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 302 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 303 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 304 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 305 | CLANG_WARN_STRICT_PROTOTYPES = YES; 306 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 307 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 308 | CLANG_WARN_UNREACHABLE_CODE = YES; 309 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 310 | COPY_PHASE_STRIP = NO; 311 | DEBUG_INFORMATION_FORMAT = dwarf; 312 | ENABLE_STRICT_OBJC_MSGSEND = YES; 313 | ENABLE_TESTABILITY = YES; 314 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 315 | GCC_C_LANGUAGE_STANDARD = gnu17; 316 | GCC_DYNAMIC_NO_PIC = NO; 317 | GCC_NO_COMMON_BLOCKS = YES; 318 | GCC_OPTIMIZATION_LEVEL = 0; 319 | GCC_PREPROCESSOR_DEFINITIONS = ( 320 | "DEBUG=1", 321 | "$(inherited)", 322 | ); 323 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 324 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 325 | GCC_WARN_UNDECLARED_SELECTOR = YES; 326 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 327 | GCC_WARN_UNUSED_FUNCTION = YES; 328 | GCC_WARN_UNUSED_VARIABLE = YES; 329 | IPHONEOS_DEPLOYMENT_TARGET = 18.2; 330 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 331 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 332 | MTL_FAST_MATH = YES; 333 | ONLY_ACTIVE_ARCH = YES; 334 | SDKROOT = iphoneos; 335 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 336 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 337 | }; 338 | name = Debug; 339 | }; 340 | 8688109D2D5FB60A00446F79 /* Release */ = { 341 | isa = XCBuildConfiguration; 342 | buildSettings = { 343 | ALWAYS_SEARCH_USER_PATHS = NO; 344 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 345 | CLANG_ANALYZER_NONNULL = YES; 346 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 347 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 348 | CLANG_ENABLE_MODULES = YES; 349 | CLANG_ENABLE_OBJC_ARC = YES; 350 | CLANG_ENABLE_OBJC_WEAK = YES; 351 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 352 | CLANG_WARN_BOOL_CONVERSION = YES; 353 | CLANG_WARN_COMMA = YES; 354 | CLANG_WARN_CONSTANT_CONVERSION = YES; 355 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 356 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 357 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 358 | CLANG_WARN_EMPTY_BODY = YES; 359 | CLANG_WARN_ENUM_CONVERSION = YES; 360 | CLANG_WARN_INFINITE_RECURSION = YES; 361 | CLANG_WARN_INT_CONVERSION = YES; 362 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 363 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 364 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 365 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 366 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 367 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 368 | CLANG_WARN_STRICT_PROTOTYPES = YES; 369 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 370 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 371 | CLANG_WARN_UNREACHABLE_CODE = YES; 372 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 373 | COPY_PHASE_STRIP = NO; 374 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 375 | ENABLE_NS_ASSERTIONS = NO; 376 | ENABLE_STRICT_OBJC_MSGSEND = YES; 377 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 378 | GCC_C_LANGUAGE_STANDARD = gnu17; 379 | GCC_NO_COMMON_BLOCKS = YES; 380 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 381 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 382 | GCC_WARN_UNDECLARED_SELECTOR = YES; 383 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 384 | GCC_WARN_UNUSED_FUNCTION = YES; 385 | GCC_WARN_UNUSED_VARIABLE = YES; 386 | IPHONEOS_DEPLOYMENT_TARGET = 18.2; 387 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 388 | MTL_ENABLE_DEBUG_INFO = NO; 389 | MTL_FAST_MATH = YES; 390 | SDKROOT = iphoneos; 391 | SWIFT_COMPILATION_MODE = wholemodule; 392 | VALIDATE_PRODUCT = YES; 393 | }; 394 | name = Release; 395 | }; 396 | 8688109F2D5FB60A00446F79 /* Debug */ = { 397 | isa = XCBuildConfiguration; 398 | buildSettings = { 399 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 400 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 401 | CODE_SIGN_STYLE = Automatic; 402 | CURRENT_PROJECT_VERSION = 1; 403 | DEVELOPMENT_ASSET_PATHS = "\"Scrumdinger/Preview Content\""; 404 | DEVELOPMENT_TEAM = TUYS5TF5HK; 405 | ENABLE_PREVIEWS = YES; 406 | ENABLE_USER_SCRIPT_SANDBOXING = NO; 407 | GENERATE_INFOPLIST_FILE = YES; 408 | INFOPLIST_FILE = Scrumdinger/Info.plist; 409 | INFOPLIST_KEY_CFBundleDisplayName = Scrumdinger; 410 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; 411 | INFOPLIST_KEY_NSMicrophoneUsageDescription = ""; 412 | INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = ""; 413 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 414 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 415 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 416 | INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; 417 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; 418 | IPHONEOS_DEPLOYMENT_TARGET = 18.0; 419 | LD_RUNPATH_SEARCH_PATHS = ( 420 | "$(inherited)", 421 | "@executable_path/Frameworks", 422 | ); 423 | MARKETING_VERSION = 1.0; 424 | PRODUCT_BUNDLE_IDENTIFIER = JamesSedlacek.Scrumdinger; 425 | PRODUCT_NAME = "$(TARGET_NAME)"; 426 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; 427 | SUPPORTS_MACCATALYST = NO; 428 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 429 | SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; 430 | SWIFT_EMIT_LOC_STRINGS = YES; 431 | SWIFT_VERSION = 5.0; 432 | TARGETED_DEVICE_FAMILY = 1; 433 | }; 434 | name = Debug; 435 | }; 436 | 868810A02D5FB60A00446F79 /* Release */ = { 437 | isa = XCBuildConfiguration; 438 | buildSettings = { 439 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 440 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 441 | CODE_SIGN_STYLE = Automatic; 442 | CURRENT_PROJECT_VERSION = 1; 443 | DEVELOPMENT_ASSET_PATHS = "\"Scrumdinger/Preview Content\""; 444 | DEVELOPMENT_TEAM = TUYS5TF5HK; 445 | ENABLE_PREVIEWS = YES; 446 | ENABLE_USER_SCRIPT_SANDBOXING = NO; 447 | GENERATE_INFOPLIST_FILE = YES; 448 | INFOPLIST_FILE = Scrumdinger/Info.plist; 449 | INFOPLIST_KEY_CFBundleDisplayName = Scrumdinger; 450 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; 451 | INFOPLIST_KEY_NSMicrophoneUsageDescription = ""; 452 | INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = ""; 453 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 454 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 455 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 456 | INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; 457 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; 458 | IPHONEOS_DEPLOYMENT_TARGET = 18.0; 459 | LD_RUNPATH_SEARCH_PATHS = ( 460 | "$(inherited)", 461 | "@executable_path/Frameworks", 462 | ); 463 | MARKETING_VERSION = 1.0; 464 | PRODUCT_BUNDLE_IDENTIFIER = JamesSedlacek.Scrumdinger; 465 | PRODUCT_NAME = "$(TARGET_NAME)"; 466 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; 467 | SUPPORTS_MACCATALYST = NO; 468 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 469 | SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; 470 | SWIFT_EMIT_LOC_STRINGS = YES; 471 | SWIFT_VERSION = 5.0; 472 | TARGETED_DEVICE_FAMILY = 1; 473 | }; 474 | name = Release; 475 | }; 476 | 868810D42D6950F700446F79 /* Debug */ = { 477 | isa = XCBuildConfiguration; 478 | buildSettings = { 479 | CODE_SIGN_STYLE = Automatic; 480 | CURRENT_PROJECT_VERSION = 1; 481 | DEVELOPMENT_TEAM = TUYS5TF5HK; 482 | GENERATE_INFOPLIST_FILE = YES; 483 | IPHONEOS_DEPLOYMENT_TARGET = 18.0; 484 | MARKETING_VERSION = 1.0; 485 | PRODUCT_BUNDLE_IDENTIFIER = JamesSedlacek.ScrumdingerUITests; 486 | PRODUCT_NAME = "$(TARGET_NAME)"; 487 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; 488 | SUPPORTS_MACCATALYST = NO; 489 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 490 | SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; 491 | SWIFT_EMIT_LOC_STRINGS = NO; 492 | SWIFT_VERSION = 5.0; 493 | TARGETED_DEVICE_FAMILY = 1; 494 | TEST_TARGET_NAME = Scrumdinger; 495 | }; 496 | name = Debug; 497 | }; 498 | 868810D52D6950F700446F79 /* Release */ = { 499 | isa = XCBuildConfiguration; 500 | buildSettings = { 501 | CODE_SIGN_STYLE = Automatic; 502 | CURRENT_PROJECT_VERSION = 1; 503 | DEVELOPMENT_TEAM = TUYS5TF5HK; 504 | GENERATE_INFOPLIST_FILE = YES; 505 | IPHONEOS_DEPLOYMENT_TARGET = 18.0; 506 | MARKETING_VERSION = 1.0; 507 | PRODUCT_BUNDLE_IDENTIFIER = JamesSedlacek.ScrumdingerUITests; 508 | PRODUCT_NAME = "$(TARGET_NAME)"; 509 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; 510 | SUPPORTS_MACCATALYST = NO; 511 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 512 | SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; 513 | SWIFT_EMIT_LOC_STRINGS = NO; 514 | SWIFT_VERSION = 5.0; 515 | TARGETED_DEVICE_FAMILY = 1; 516 | TEST_TARGET_NAME = Scrumdinger; 517 | }; 518 | name = Release; 519 | }; 520 | /* End XCBuildConfiguration section */ 521 | 522 | /* Begin XCConfigurationList section */ 523 | 8688108B2D5FB60800446F79 /* Build configuration list for PBXProject "Scrumdinger" */ = { 524 | isa = XCConfigurationList; 525 | buildConfigurations = ( 526 | 8688109C2D5FB60A00446F79 /* Debug */, 527 | 8688109D2D5FB60A00446F79 /* Release */, 528 | ); 529 | defaultConfigurationIsVisible = 0; 530 | defaultConfigurationName = Release; 531 | }; 532 | 8688109E2D5FB60A00446F79 /* Build configuration list for PBXNativeTarget "Scrumdinger" */ = { 533 | isa = XCConfigurationList; 534 | buildConfigurations = ( 535 | 8688109F2D5FB60A00446F79 /* Debug */, 536 | 868810A02D5FB60A00446F79 /* Release */, 537 | ); 538 | defaultConfigurationIsVisible = 0; 539 | defaultConfigurationName = Release; 540 | }; 541 | 868810D32D6950F700446F79 /* Build configuration list for PBXNativeTarget "ScrumdingerUITests" */ = { 542 | isa = XCConfigurationList; 543 | buildConfigurations = ( 544 | 868810D42D6950F700446F79 /* Debug */, 545 | 868810D52D6950F700446F79 /* Release */, 546 | ); 547 | defaultConfigurationIsVisible = 0; 548 | defaultConfigurationName = Release; 549 | }; 550 | /* End XCConfigurationList section */ 551 | 552 | /* Begin XCRemoteSwiftPackageReference section */ 553 | FAC649CC2DD61438000FD608 /* XCRemoteSwiftPackageReference "SwiftLint" */ = { 554 | isa = XCRemoteSwiftPackageReference; 555 | repositoryURL = "https://github.com/realm/SwiftLint.git"; 556 | requirement = { 557 | kind = upToNextMajorVersion; 558 | minimumVersion = 0.59.1; 559 | }; 560 | }; 561 | /* End XCRemoteSwiftPackageReference section */ 562 | 563 | /* Begin XCSwiftPackageProductDependency section */ 564 | 868810AC2D5FC2E100446F79 /* Scrumdinger */ = { 565 | isa = XCSwiftPackageProductDependency; 566 | productName = Scrumdinger; 567 | }; 568 | 868810AE2D5FC2E100446F79 /* Models */ = { 569 | isa = XCSwiftPackageProductDependency; 570 | productName = Models; 571 | }; 572 | 868810B02D5FC2E100446F79 /* Services */ = { 573 | isa = XCSwiftPackageProductDependency; 574 | productName = Services; 575 | }; 576 | 868810B22D5FC2E100446F79 /* ViewComponents */ = { 577 | isa = XCSwiftPackageProductDependency; 578 | productName = ViewComponents; 579 | }; 580 | FAC649CD2DD61623000FD608 /* SwiftLintBuildToolPlugin */ = { 581 | isa = XCSwiftPackageProductDependency; 582 | package = FAC649CC2DD61438000FD608 /* XCRemoteSwiftPackageReference "SwiftLint" */; 583 | productName = "plugin:SwiftLintBuildToolPlugin"; 584 | }; 585 | /* End XCSwiftPackageProductDependency section */ 586 | }; 587 | rootObject = 868810882D5FB60800446F79 /* Project object */; 588 | } 589 | -------------------------------------------------------------------------------- /Scrumdinger.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Scrumdinger.xcodeproj/project.xcworkspace/xcuserdata/jamessedlacek.xcuserdatad/UserInterfaceState.xcuserstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JamesSedlacek/Scrumdinger/d575833721b0428ab407ceef10a7d43cffb7ad9f/Scrumdinger.xcodeproj/project.xcworkspace/xcuserdata/jamessedlacek.xcuserdatad/UserInterfaceState.xcuserstate -------------------------------------------------------------------------------- /Scrumdinger.xcodeproj/xcshareddata/xcschemes/Scrumdinger.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 31 | 32 | 35 | 36 | 38 | 39 | 40 | 41 | 44 | 50 | 51 | 52 | 53 | 54 | 64 | 66 | 72 | 73 | 74 | 75 | 81 | 83 | 89 | 90 | 91 | 92 | 94 | 95 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /Scrumdinger.xcodeproj/xcshareddata/xcschemes/ScrumdingerUITests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 17 | 20 | 26 | 27 | 28 | 29 | 30 | 40 | 42 | 48 | 49 | 50 | 51 | 57 | 58 | 64 | 65 | 66 | 67 | 69 | 70 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /Scrumdinger.xcodeproj/xcuserdata/jamessedlacek.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /Scrumdinger.xcodeproj/xcuserdata/jamessedlacek.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | Scrumdinger.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | ScrumdingerUITests.xcscheme_^#shared#^_ 13 | 14 | orderHint 15 | 1 16 | 17 | 18 | SuppressBuildableAutocreation 19 | 20 | 8688108F2D5FB60800446F79 21 | 22 | primary 23 | 24 | 25 | 868810CA2D6950F700446F79 26 | 27 | primary 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /Scrumdinger/App/ScrumdingerApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScrumdingerApp.swift 3 | // 4 | // Created by James Sedlacek on 2/14/25. 5 | // 6 | 7 | import Configuration 8 | import Scrum 9 | import SwiftUI 10 | 11 | @main 12 | struct ScrumdingerApp: App { 13 | var body: some Scene { 14 | AppScene(content: ScrumScreen.init) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Scrumdinger/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Scrumdinger/Assets.xcassets/AppIcon.appiconset/AppIcon1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JamesSedlacek/Scrumdinger/d575833721b0428ab407ceef10a7d43cffb7ad9f/Scrumdinger/Assets.xcassets/AppIcon.appiconset/AppIcon1024@1x.png -------------------------------------------------------------------------------- /Scrumdinger/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "AppIcon1024@1x.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Scrumdinger/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Scrumdinger/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSMicrophoneUsageDescription 6 | Audio is recorded to transcribe the meeting. Audio recordings are discarded after transcription. 7 | NSSpeechRecognitionUsageDescription 8 | You can view a text transcription of your meeting in the app. 9 | 10 | 11 | -------------------------------------------------------------------------------- /Scrumdinger/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Scrumdinger/TestPlan.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "BF84507A-CDDB-4A34-AA2D-4C292D192641", 5 | "name" : "Configuration 1", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | "testTimeoutsEnabled" : true 13 | }, 14 | "testTargets" : [ 15 | { 16 | "target" : { 17 | "containerPath" : "container:Models", 18 | "identifier" : "ExtensionsTests", 19 | "name" : "ExtensionsTests" 20 | } 21 | }, 22 | { 23 | "parallelizable" : true, 24 | "target" : { 25 | "containerPath" : "container:Scrumdinger.xcodeproj", 26 | "identifier" : "868810CA2D6950F700446F79", 27 | "name" : "ScrumdingerUITests" 28 | } 29 | } 30 | ], 31 | "version" : 1 32 | } 33 | -------------------------------------------------------------------------------- /ScrumdingerUITests/Elements/Button.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Button.swift 3 | // 4 | // Created by James Sedlacek on 3/2/24. 5 | // 6 | 7 | import Foundation 8 | import XCTest 9 | 10 | enum Button: String { 11 | case add 12 | case addAttendee = "Add Attendee" 13 | case backToDailyScrums = "Daily Scrums" 14 | case cancel 15 | case delete 16 | case dismiss 17 | case done 18 | case edit 19 | case end 20 | case forward = "forward.fill" 21 | case orange 22 | case oxblood 23 | case paintpalette 24 | case plus 25 | case startMeeting = "Start Meeting" 26 | 27 | var element: XCUIElement { 28 | switch self { 29 | case .add: 30 | XCUIApplication().navigationBars.buttons.element(boundBy: 2) 31 | case .dismiss: 32 | XCUIApplication().navigationBars.buttons[rawValue.capitalized] 33 | case .plus: 34 | XCUIApplication().navigationBars.buttons[rawValue] 35 | case .forward, .paintpalette: 36 | XCUIApplication().buttons[rawValue] 37 | case .startMeeting: 38 | XCUIApplication().staticTexts[rawValue] 39 | default: 40 | XCUIApplication().buttons[rawValue.capitalized] 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /ScrumdingerUITests/Elements/ProgressView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProgressView.swift 3 | // 4 | // Created by James Sedlacek on 2/22/25. 5 | // 6 | 7 | import Foundation 8 | import XCTest 9 | 10 | enum ProgressView: String { 11 | case ring = "MeetingProgressRing" 12 | case speaker = "SpeakerProgressView" 13 | 14 | var element: XCUIElement { 15 | switch self { 16 | case .ring: 17 | XCUIApplication().otherElements[rawValue].firstMatch 18 | case .speaker: 19 | XCUIApplication().progressIndicators[rawValue].firstMatch 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /ScrumdingerUITests/Elements/Slider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Slider.swift 3 | // 4 | // Created by James Sedlacek on 2/21/25. 5 | // 6 | 7 | import Foundation 8 | import XCTest 9 | 10 | enum Slider: String { 11 | case length 12 | 13 | var element: XCUIElement { 14 | switch self { 15 | default: 16 | XCUIApplication().sliders[rawValue.capitalized] 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /ScrumdingerUITests/Elements/TextField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextField.swift 3 | // 4 | // Created by James Sedlacek on 2/21/25. 5 | // 6 | 7 | import Foundation 8 | import XCTest 9 | 10 | enum TextField: String { 11 | case newAttendee = "New Attendee" 12 | case title 13 | 14 | var element: XCUIElement { 15 | switch self { 16 | default: 17 | XCUIApplication().textFields[rawValue.capitalized] 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /ScrumdingerUITests/Elements/Title.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Title.swift 3 | // 4 | // Created by James Sedlacek on 3/2/24. 5 | // 6 | 7 | import Foundation 8 | import XCTest 9 | 10 | enum Title: String { 11 | case dailyScrums = "Daily Scrums" 12 | 13 | var element: XCUIElement { 14 | XCUIApplication().navigationBars[rawValue] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /ScrumdingerUITests/Elements/View.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View.swift 3 | // 4 | // Created by James Sedlacek on 2/21/25. 5 | // 6 | 7 | import Foundation 8 | import XCTest 9 | 10 | enum View: String { 11 | case scrumCard = "ScrumCard" 12 | 13 | var element: XCUIElement { 14 | switch self { 15 | case .scrumCard: 16 | XCUIApplication().staticTexts.matching( 17 | NSPredicate( 18 | format: "identifier BEGINSWITH %@", rawValue 19 | ) 20 | ).firstMatch 21 | } 22 | } 23 | 24 | // Returns a specific scrum card element based on meeting title 25 | static func scrumCard(withTitle title: String) -> XCUIElement { 26 | XCUIApplication().staticTexts[title].firstMatch 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /ScrumdingerUITests/ROBOT-PATTERN-README.md: -------------------------------------------------------------------------------- 1 | # Robot Pattern 2 | 3 | The Robot Pattern is a UI testing strategy that abstracts interactions with the app into reusable, structured "robot" classes. Instead of writing UI tests directly with raw `XCUIApplication` calls, robots encapsulate actions and verifications, making tests more readable, maintainable, and modular. 4 | 5 | --- 6 | 7 | ## How It Works 8 | 9 | ### 1. Each screen or flow has its own robot 10 | - A robot is a helper class responsible for interacting with a specific part of the app (e.g., `AddScrumRobot`, `DetailScrumRobot`). 11 | - It provides high-level methods like `setDurationSlider()` or `addAttendees()`, abstracting away raw UI interactions. 12 | 13 | ### 2. Tests chain robot methods for clarity 14 | - Each robot method returns the robot for the next part of the flow. This could be the same robot if the test continues on the same screen or a different robot if the test moves to another part of the app. 15 | - This makes tests fluent and readable, ensuring each step naturally leads to the next. 16 | 17 | #### Example: 18 | ```swift 19 | AppRobot() 20 | .launchApp() // Returns ScrumListRobot 21 | .tapAddScrumButton() // Returns AddScrumRobot 22 | .tapCreateScrumButton() // Stays on AddScrumRobot 23 | .verifyAttendeeCountExists(count: 0) // Stays on AddScrumRobot 24 | .verifyMeetingLengthExists(minutes: 5) // Stays on AddScrumRobot 25 | ``` 26 | 27 | ## The Elements of a Robot 28 | 29 | - **Broken into Elements, Verifications, and Actions** 30 | A robot is structured around three key components: 31 | - **Elements**: The UI components the robot interacts with (e.g., buttons, text fields). 32 | - **Verifications**: Methods to check the state of the UI (e.g., `validateScrumExists()`). 33 | - **Actions**: Methods that perform actions on the UI (e.g., `fillOutScrumDetails()`). 34 | 35 | - **Each Robot has an `init` Validating We Are on the Correct Screen** 36 | The `init` method of each robot ensures that the app is in the correct screen state when the robot is instantiated. If not, it performs the necessary navigation or throws an error. This removes the need for explicit validation methods in the test, simplifying the flow and making the test code cleaner. 37 | 38 | ## The App Robot 39 | 40 | At the core of the pattern is the AppRobot, responsible for launching the app and returning the robot for the initial screen. Every test starts by creating an `AppRobot`, ensuring a consistent entry point. 41 | 42 | The `AppRobot` also manages common launch flows. For example, it includes `launchAppWithNewScrum()`, which not only launches the app but also sets up a basic scrum and verifies its creation. This makes it easy for tests that rely on this setup to use a standardized launch sequence. 43 | 44 | -------------------------------------------------------------------------------- /ScrumdingerUITests/Robots/AppRobot/AppRobot.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppRobot.swift 3 | // Scrumdinger 4 | // 5 | // Created by Matt Heaney on 24/02/2025. 6 | // 7 | 8 | import Foundation 9 | 10 | class AppRobot: Robot { 11 | 12 | @discardableResult 13 | func launchApp() -> ScrumListRobot { 14 | app.launch() 15 | return ScrumListRobot() 16 | } 17 | 18 | /// Launches the app and handles the standard process to add a new scrum meeting, 19 | /// validating the scrum details appear as expected in the scrum list 20 | @discardableResult 21 | func launchAppWithNewScrum(scrumName: String = "Design Meeting", 22 | attendees: [String] = ["John", "Alice", "Bob"], 23 | meetingLengthValue: CGFloat = 1.0, 24 | meetingMinutes: Int = 30) -> ScrumListRobot { 25 | self.launchApp() 26 | .tapAddScrumButton() 27 | .inputScrumTitle(scrumName) 28 | .setDurationSlider(meetingLengthValue) 29 | .tapThemeSelectionButton() 30 | .tapThemeOrangeButton() 31 | .addAttendees(attendees) 32 | .tapCreateScrumButton() 33 | .verifyScrumTitleExists(named: scrumName) 34 | .verifyAttendeeCountExists(count: attendees.count) 35 | .verifyMeetingLengthExists(minutes: meetingMinutes) 36 | } 37 | 38 | func terminateApp() { 39 | app.terminate() 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /ScrumdingerUITests/Robots/AppRobot/Robot.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Robot.swift 3 | // Scrumdinger 4 | // 5 | // Created by Matt Heaney on 24/02/2025. 6 | // 7 | 8 | import Foundation 9 | import XCTest 10 | 11 | protocol Robot {} 12 | 13 | extension Robot { 14 | var app: XCUIApplication { 15 | XCUIApplication() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /ScrumdingerUITests/Robots/ViewRobots/AddScrumRobot.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AddScrumRobot.swift 3 | // Scrumdinger 4 | // 5 | // Created by Matt Heaney on 24/02/2025. 6 | // 7 | 8 | import Foundation 9 | import XCTest 10 | 11 | class AddScrumRobot: Robot { 12 | 13 | init() { 14 | XCTAssertTrue( 15 | addScrumButton.waitForExistence(timeout: 5), 16 | "Expected 'AddScrumRobot' screen, but it didn't appear" 17 | ) 18 | } 19 | 20 | // MARK: - Elements 21 | private var addScrumButton: XCUIElement { 22 | Button.add.element 23 | } 24 | 25 | private var titleTextField: XCUIElement { 26 | TextField.title.element 27 | } 28 | 29 | private var durationSlider: XCUIElement { 30 | Slider.length.element 31 | } 32 | 33 | private var themeSelectionButton: XCUIElement { 34 | Button.paintpalette.element 35 | } 36 | 37 | private var themeOrangeButton: XCUIElement { 38 | Button.orange.element 39 | } 40 | 41 | private var themeOxbloodButton: XCUIElement { 42 | Button.oxblood.element 43 | } 44 | 45 | private var attendeeNameField: XCUIElement { 46 | TextField.newAttendee.element 47 | } 48 | 49 | private var addAttendeeButton: XCUIElement { 50 | Button.addAttendee.element 51 | } 52 | 53 | private func attendeeCell(for name: String) -> XCUIElement { 54 | app.staticTexts[name] 55 | } 56 | 57 | private var deleteAttendeeButton: XCUIElement { 58 | Button.delete.element 59 | } 60 | 61 | private var cancelButton: XCUIElement { 62 | Button.cancel.element 63 | } 64 | 65 | private var doneButton: XCUIElement { 66 | Button.done.element 67 | } 68 | 69 | private var dismissScrumButton: XCUIElement { 70 | Button.dismiss.element 71 | } 72 | 73 | // MARK: - Validation 74 | 75 | // (Placeholder for validation methods, if needed) 76 | 77 | // MARK: - Interaction 78 | 79 | @discardableResult 80 | func inputScrumTitle(_ text: String) -> Self { 81 | titleTextField.tap() 82 | titleTextField.typeText(text) 83 | return self 84 | } 85 | 86 | @discardableResult 87 | func setDurationSlider(_ duration: CGFloat) -> Self { 88 | durationSlider.adjust(toNormalizedSliderPosition: duration) 89 | return self 90 | } 91 | 92 | @discardableResult 93 | func addAttendees(_ attendees: [String]) -> Self { 94 | attendees.forEach { 95 | attendeeNameField.tap() 96 | attendeeNameField.typeText($0) 97 | addAttendeeButton.tap() 98 | } 99 | return self 100 | } 101 | 102 | @discardableResult 103 | func deleteAttendee(named attendee: String) -> Self { 104 | attendeeCell(for: attendee).swipeLeft() 105 | deleteAttendeeButton.tap() 106 | return self 107 | } 108 | 109 | @discardableResult 110 | func tapCreateScrumButton() -> ScrumListRobot { 111 | addScrumButton.tap() 112 | return ScrumListRobot() 113 | } 114 | 115 | @discardableResult 116 | func tapDismissScrumButton() -> ScrumListRobot { 117 | dismissScrumButton.tap() 118 | return ScrumListRobot() 119 | } 120 | 121 | @discardableResult 122 | func tapThemeSelectionButton() -> AddScrumRobot { 123 | themeSelectionButton.tap() 124 | return self 125 | } 126 | 127 | @discardableResult 128 | func tapThemeOrangeButton() -> AddScrumRobot { 129 | themeOrangeButton.tap() 130 | return self 131 | } 132 | 133 | @discardableResult 134 | func tapThemeOxbloodButton() -> AddScrumRobot { 135 | themeOxbloodButton.tap() 136 | return self 137 | } 138 | 139 | @discardableResult 140 | func tapCancelButton() -> DetailScrumRobot { 141 | cancelButton.tap() 142 | return DetailScrumRobot() 143 | } 144 | 145 | @discardableResult 146 | func tapDoneButton() -> DetailScrumRobot { 147 | doneButton.tap() 148 | return DetailScrumRobot() 149 | } 150 | 151 | } 152 | -------------------------------------------------------------------------------- /ScrumdingerUITests/Robots/ViewRobots/DetailScrumRobot.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DetailScrumRobot.swift 3 | // Scrumdinger 4 | // 5 | // Created by Matt Heaney on 25/02/2025. 6 | // 7 | 8 | import XCTest 9 | 10 | class DetailScrumRobot: Robot { 11 | 12 | init() { 13 | XCTAssertTrue( 14 | meetingInfoTitle.waitForExistence(timeout: 5), 15 | "Expected 'Daily Scrum' screen, but it didn't appear" 16 | ) 17 | } 18 | 19 | // MARK: - Elements 20 | 21 | private var meetingInfoTitle: XCUIElement { 22 | app.staticTexts["MEETING INFO"] 23 | } 24 | 25 | private func meetingTitleLabel(named title: String) -> XCUIElement { 26 | app.staticTexts[title] 27 | } 28 | 29 | private func meetingThemeLabel(named theme: String) -> XCUIElement { 30 | app.staticTexts[theme] 31 | } 32 | 33 | private func attendeeCountLabel(for count: Int) -> XCUIElement { 34 | app.staticTexts["\(count) attendees"] 35 | } 36 | 37 | private func meetingLengthLabel(for minutes: Int) -> XCUIElement { 38 | app.staticTexts["\(minutes) minutes"] 39 | } 40 | 41 | private func detailLabel(text: String) -> XCUIElement { 42 | app.staticTexts[text] 43 | } 44 | 45 | private func meetingHistoryRow(for date: String) -> XCUIElement { 46 | app.staticTexts[date].firstMatch 47 | } 48 | 49 | private var backButton: XCUIElement { 50 | Button.backToDailyScrums.element 51 | } 52 | 53 | private var editButton: XCUIElement { 54 | Button.edit.element 55 | } 56 | 57 | private var startMeetingButton: XCUIElement { 58 | Button.startMeeting.element 59 | } 60 | 61 | // MARK: - Interactions 62 | 63 | @discardableResult 64 | func tapBackButton() -> ScrumListRobot { 65 | backButton.tap() 66 | return ScrumListRobot() 67 | } 68 | 69 | @discardableResult 70 | func tapEditButton() -> AddScrumRobot { 71 | editButton.tap() 72 | return AddScrumRobot() 73 | } 74 | 75 | @discardableResult 76 | func tapStartMeetingButton() -> MeetingScrumRobot { 77 | startMeetingButton.tap() 78 | return MeetingScrumRobot() 79 | } 80 | 81 | @discardableResult 82 | func tapHistoricalMeeting() -> HistoricalMeetingRobot { 83 | let dateFormatter = DateFormatter() 84 | dateFormatter.dateFormat = "d MMMM yyyy" 85 | let todaysDate = dateFormatter.string(from: .now) 86 | 87 | meetingHistoryRow(for: todaysDate).tap() 88 | return HistoricalMeetingRobot() 89 | } 90 | 91 | // MARK: - Validations 92 | 93 | @discardableResult 94 | func verifyMeetingTitleExists(named title: String) -> Self { 95 | XCTAssertTrue(meetingTitleLabel(named: title).exists) 96 | return self 97 | } 98 | 99 | @discardableResult 100 | func verifyMeetingThemeExists(named theme: String) -> Self { 101 | XCTAssertTrue(meetingThemeLabel(named: theme).exists) 102 | return self 103 | } 104 | 105 | @discardableResult 106 | func verifyAttendeeCountExists(count: Int) -> Self { 107 | XCTAssertTrue(attendeeCountLabel(for: count).exists) 108 | return self 109 | } 110 | 111 | @discardableResult 112 | func verifyMeetingLengthExists(minutes: Int) -> Self { 113 | XCTAssertTrue(meetingLengthLabel(for: minutes).exists) 114 | return self 115 | } 116 | 117 | @discardableResult 118 | func verifyDetailLabelExists(text: String) -> Self { 119 | XCTAssertTrue(detailLabel(text: text).exists) 120 | return self 121 | } 122 | 123 | @discardableResult 124 | func verifyAttendeeExists(named name: String) -> Self { 125 | XCTAssertTrue(detailLabel(text: name).exists) 126 | return self 127 | } 128 | 129 | @discardableResult 130 | func verifyMeetingHistoryExists(exists: Bool) -> Self { 131 | let dateFormatter = DateFormatter() 132 | dateFormatter.dateFormat = "d MMMM yyyy" 133 | let todaysDate = dateFormatter.string(from: .now) 134 | 135 | let historyRow = meetingHistoryRow(for: todaysDate) 136 | 137 | if exists { 138 | XCTAssertTrue( 139 | historyRow.waitForExistence(timeout: 2), 140 | "Expected meeting history row to exist but it doesn't." 141 | ) 142 | } else { 143 | XCTAssertFalse( 144 | historyRow.waitForExistence(timeout: 2), 145 | "Expected meeting history row to be absent but it exists." 146 | ) 147 | } 148 | 149 | return self 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /ScrumdingerUITests/Robots/ViewRobots/HistoricalMeetingRobot.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HistoricalMeetingRobot.swift 3 | // Scrumdinger 4 | // 5 | // Created by Matt Heaney on 25/02/2025. 6 | // 7 | 8 | import XCTest 9 | 10 | class HistoricalMeetingRobot: Robot { 11 | 12 | init() { 13 | XCTAssertTrue( 14 | transcriptTitle.waitForExistence(timeout: 5), 15 | "Expected 'Historical Meeting' screen, but it didn't appear" 16 | ) 17 | } 18 | 19 | // MARK: - Elements 20 | 21 | private var transcriptTitle: XCUIElement { 22 | app.staticTexts["Transcript"] 23 | } 24 | 25 | private func speakerLabel(named name: String) -> XCUIElement { 26 | app.staticTexts[name] 27 | } 28 | 29 | private var transcriptText: XCUIElement { 30 | app.staticTexts.matching( 31 | NSPredicate(format: "label CONTAINS[c] %@", "Transcript") 32 | ).firstMatch 33 | } 34 | 35 | // MARK: - Validations 36 | 37 | @discardableResult 38 | func verifySpeakersExists(named name: String) -> Self { 39 | XCTAssertTrue(speakerLabel(named: name).exists) 40 | return self 41 | } 42 | 43 | @discardableResult 44 | func verifyTranscriptExists() -> Self { 45 | XCTAssertTrue(transcriptText.exists) 46 | return self 47 | } 48 | 49 | // MARK: - Interactions 50 | } 51 | -------------------------------------------------------------------------------- /ScrumdingerUITests/Robots/ViewRobots/MeetingScrumRobot.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MeetingScrumRobot.swift 3 | // Scrumdinger 4 | // 5 | // Created by Matt Heaney on 25/02/2025. 6 | // 7 | 8 | import XCTest 9 | 10 | class MeetingScrumRobot: Robot { 11 | 12 | init() { 13 | XCTAssertTrue( 14 | secondsElapsedLabel.waitForExistence(timeout: 5), 15 | "Expected 'Meeting Scrum' screen, but it didn't appear" 16 | ) 17 | } 18 | 19 | // MARK: - Elements 20 | 21 | private func speakerLabel(named name: String) -> XCUIElement { 22 | app.staticTexts[name] 23 | } 24 | 25 | private var skipButton: XCUIElement { 26 | Button.forward.element 27 | } 28 | 29 | private var secondsElapsedLabel: XCUIElement { 30 | app.staticTexts["Seconds Elapsed"] 31 | } 32 | 33 | private func backToScrumButton(named meetingName: String) -> XCUIElement { 34 | app.buttons[meetingName] 35 | } 36 | 37 | // MARK: - Validations 38 | 39 | @discardableResult 40 | func verifySpeakerExists(named name: String) -> Self { 41 | XCTAssertTrue(speakerLabel(named: name).exists) 42 | return self 43 | } 44 | 45 | // MARK: - Interactions 46 | 47 | @discardableResult 48 | func tapSkipButton() -> Self { 49 | skipButton.tap() 50 | return self 51 | } 52 | 53 | @discardableResult 54 | func tapBackToScrum(named meetingName: String) -> DetailScrumRobot { 55 | backToScrumButton(named: meetingName).tap() 56 | return DetailScrumRobot() 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /ScrumdingerUITests/Robots/ViewRobots/ScrumListRobot.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScrumListRobot.swift 3 | // Scrumdinger 4 | // 5 | // Created by Matt Heaney on 24/02/2025. 6 | // 7 | 8 | import XCTest 9 | 10 | class ScrumListRobot: Robot { 11 | 12 | init() { 13 | XCTAssertTrue(titleLabel.waitForExistence(timeout: 5), "Expected 'ScrumListRobot' screen, but it didn't appear") 14 | } 15 | 16 | // MARK: - Elements 17 | 18 | private var addScrumButton: XCUIElement { 19 | Button.plus.element 20 | } 21 | 22 | private var titleLabel: XCUIElement { 23 | Title.dailyScrums.element 24 | } 25 | 26 | private func scrumCard(withTitle title: String) -> XCUIElement { 27 | View.scrumCard(withTitle: title) 28 | } 29 | 30 | private func scrumTitleLabel(named title: String) -> XCUIElement { 31 | app.staticTexts[title] 32 | } 33 | 34 | private func attendeeCountLabel(for count: Int) -> XCUIElement { 35 | app.staticTexts 36 | .matching(identifier: "\(count) attendees") 37 | .firstMatch 38 | } 39 | 40 | private func meetingLengthLabel(for minutes: Int) -> XCUIElement { 41 | app.staticTexts 42 | .matching(identifier: "\(minutes) minute meeting") 43 | .firstMatch 44 | } 45 | 46 | // MARK: - Validations 47 | 48 | @discardableResult 49 | func verifyScrumTitleExists(named title: String) -> Self { 50 | XCTAssertTrue(scrumTitleLabel(named: title).exists) 51 | return self 52 | } 53 | 54 | @discardableResult 55 | func verifyAttendeeCountExists(count: Int) -> Self { 56 | XCTAssertTrue(attendeeCountLabel(for: count).exists) 57 | return self 58 | } 59 | 60 | @discardableResult 61 | func verifyMeetingLengthExists(minutes: Int) -> Self { 62 | XCTAssertTrue(meetingLengthLabel(for: minutes).exists) 63 | return self 64 | } 65 | 66 | // MARK: - Interactions 67 | 68 | @discardableResult 69 | func tapAddScrumButton() -> AddScrumRobot { 70 | addScrumButton.tap() 71 | return AddScrumRobot() 72 | } 73 | 74 | @discardableResult 75 | func tapScrumCard(withTitle title: String) -> DetailScrumRobot { 76 | scrumCard(withTitle: title).tap() 77 | return DetailScrumRobot() 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /ScrumdingerUITests/Tests/AddScrumTests/AddScrumTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AddScrumTests.swift 3 | // 4 | // Created by James Sedlacek on 2/21/25. 5 | // 6 | 7 | import XCTest 8 | 9 | class AddScrumTests: XCTestCase { 10 | func testDismissAddScrum() { 11 | AppRobot() 12 | .launchApp() 13 | .tapAddScrumButton() 14 | .tapDismissScrumButton() 15 | } 16 | 17 | func testAddEmptyScrum() { 18 | AppRobot() 19 | .launchApp() 20 | .tapAddScrumButton() 21 | .tapCreateScrumButton() 22 | .verifyAttendeeCountExists(count: 0) 23 | .verifyMeetingLengthExists(minutes: 5) 24 | } 25 | 26 | func testAddScrum() { 27 | AppRobot() 28 | .launchAppWithNewScrum() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /ScrumdingerUITests/Tests/DetailScrumTests/DetailScrumTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DetailScrumTests.swift 3 | // 4 | // Created by James Sedlacek on 2/21/25. 5 | // 6 | 7 | import XCTest 8 | 9 | class DetailScrumTests: XCTestCase { 10 | func testScrumDetailsInfo() { 11 | AppRobot() 12 | .launchAppWithNewScrum() 13 | .tapScrumCard(withTitle: "Design Meeting") 14 | .verifyMeetingTitleExists(named: "Design Meeting") 15 | .verifyMeetingLengthExists(minutes: 30) 16 | .verifyMeetingThemeExists(named: "Orange") 17 | .verifyAttendeeExists(named: "John") 18 | .verifyAttendeeExists(named: "Alice") 19 | .verifyAttendeeExists(named: "Bob") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /ScrumdingerUITests/Tests/EditScrumTests/EditScrumTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EditScrumTests.swift 3 | // 4 | // Created by James Sedlacek on 2/22/25. 5 | // 6 | 7 | import XCTest 8 | 9 | class EditScrumTests: XCTestCase { 10 | func testCancelAllChanges() { 11 | AppRobot() 12 | .launchAppWithNewScrum() 13 | .tapScrumCard(withTitle: "Design Meeting") 14 | .verifyMeetingThemeExists(named: "Orange") 15 | .tapEditButton() 16 | .inputScrumTitle(" Changed") 17 | .setDurationSlider(0.0) 18 | .tapThemeSelectionButton() 19 | .tapThemeOxbloodButton() 20 | .addAttendees(["Bob"]) 21 | .tapCancelButton() 22 | .verifyMeetingThemeExists(named: "Orange") 23 | .tapBackButton() 24 | .verifyAttendeeCountExists(count: 3) 25 | .verifyMeetingLengthExists(minutes: 30) 26 | } 27 | 28 | func testEditTitle() { 29 | AppRobot() 30 | .launchAppWithNewScrum() 31 | .tapScrumCard(withTitle: "Design Meeting") 32 | .tapEditButton() 33 | .inputScrumTitle(" Changed") 34 | .tapDoneButton() 35 | .tapBackButton() 36 | .verifyScrumTitleExists(named: "Design Meeting Changed") 37 | } 38 | 39 | func testEditLength() { 40 | AppRobot() 41 | .launchAppWithNewScrum() 42 | .tapScrumCard(withTitle: "Design Meeting") 43 | .tapEditButton() 44 | .setDurationSlider(0.0) 45 | .tapDoneButton() 46 | .tapBackButton() 47 | .verifyMeetingLengthExists(minutes: 5) 48 | } 49 | 50 | func testAddAttendee() { 51 | AppRobot() 52 | .launchAppWithNewScrum(attendees: ["John"]) 53 | .tapScrumCard(withTitle: "Design Meeting") 54 | .tapEditButton() 55 | .addAttendees(["Matt"]) 56 | .tapDoneButton() 57 | .tapBackButton() 58 | .verifyAttendeeCountExists(count: 2) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /ScrumdingerUITests/Tests/MeetingScrumTests/MeetingScrumTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MeetingScrumTests.swift 3 | // 4 | // Created by James Sedlacek on 2/22/25. 5 | // 6 | 7 | import XCTest 8 | 9 | class MeetingScrumTests: XCTestCase { 10 | func testSkipSpeaker() { 11 | AppRobot() 12 | .launchAppWithNewScrum(attendees: ["John", "Alice"]) 13 | .tapScrumCard(withTitle: "Design Meeting") 14 | .tapStartMeetingButton() 15 | .verifySpeakerExists(named: "John") 16 | .tapSkipButton() 17 | .verifySpeakerExists(named: "Alice") 18 | } 19 | 20 | func testEndMeetingEarly() { 21 | AppRobot() 22 | .launchAppWithNewScrum(attendees: ["John", "Alice"]) 23 | .tapScrumCard(withTitle: "Design Meeting") 24 | .verifyMeetingHistoryExists(exists: false) 25 | .tapStartMeetingButton() 26 | .tapBackToScrum(named: "Design Meeting") 27 | .verifyMeetingHistoryExists(exists: true) 28 | .tapHistoricalMeeting() 29 | .verifySpeakersExists(named: "John and Alice") 30 | .verifyTranscriptExists() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /ScrumdingerUITests/Tests/ScrumListTests/ScrumListTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ScrumListTests.swift 3 | // Scrumdinger 4 | // 5 | // Created by Matt Heaney on 24/02/2025. 6 | // 7 | 8 | import XCTest 9 | 10 | class ScrumListTests: XCTestCase { 11 | func testAppLaunchedInAddScrumScreen() { 12 | AppRobot() 13 | .launchApp() 14 | } 15 | 16 | override func tearDownWithError() throws { 17 | AppRobot() 18 | .terminateApp() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Services/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Services/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Services", 8 | platforms: [.iOS(.v17), .macOS(.v14)], 9 | products: PackageProduct.allCases.map(\.description), 10 | targets: InternalTarget.allCases.map(\.target) 11 | ) 12 | 13 | // MARK: PackageProduct 14 | 15 | private enum PackageProduct: CaseIterable { 16 | case services 17 | 18 | var name: String { 19 | switch self { 20 | case .services: "Services" 21 | } 22 | } 23 | 24 | var targets: [InternalTarget] { 25 | switch self { 26 | case .services: 27 | InternalTarget.allCases 28 | } 29 | } 30 | 31 | var description: PackageDescription.Product { 32 | switch self { 33 | case .services: 34 | .library( 35 | name: self.name, 36 | targets: self.targets.map(\.title) 37 | ) 38 | } 39 | } 40 | } 41 | 42 | // MARK: InternalTarget 43 | 44 | private enum InternalTarget: CaseIterable { 45 | case audioService 46 | case fileService 47 | case speechService 48 | 49 | var title: String { 50 | switch self { 51 | case .audioService: "AudioService" 52 | case .fileService: "FileService" 53 | case .speechService: "SpeechService" 54 | } 55 | } 56 | 57 | var resources: [Resource]? { 58 | switch self { 59 | case .audioService: 60 | [ 61 | .process("Resources") 62 | ] 63 | default: nil 64 | } 65 | } 66 | 67 | var target: Target { 68 | .target( 69 | name: self.title, 70 | dependencies: [], 71 | resources: self.resources 72 | ) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Services/Sources/AudioService/AudioService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AudioService.swift 3 | // 4 | // Created by James Sedlacek on 2/17/25. 5 | // 6 | 7 | import AVFoundation 8 | import SwiftUI 9 | 10 | /// A service that manages audio playback using AVPlayer. 11 | /// Provides caching of players for improved performance. 12 | public final class AudioService: @unchecked Sendable { 13 | private var cachedPlayers: [SoundFile.ID: AVAudioPlayer] = [:] 14 | 15 | public init() { 16 | print("Initialized AudioService...") 17 | } 18 | } 19 | 20 | extension AudioService: AudioServiceProtocol { 21 | public func play(_ file: SoundFile) throws(AudioServiceError) { 22 | if let player = cachedPlayers[file] { 23 | player.currentTime = 0 24 | player.play() 25 | return 26 | } 27 | 28 | guard let url = file.url else { 29 | throw .fileNotFound 30 | } 31 | 32 | do { 33 | let player = try AVAudioPlayer(contentsOf: url) 34 | player.prepareToPlay() 35 | cachedPlayers[file.id] = player 36 | player.play() 37 | } catch { 38 | throw .initializationFailed 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Services/Sources/AudioService/AudioServiceError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AudioServiceError.swift 3 | // 4 | // Created by James Sedlacek on 2/18/25. 5 | // 6 | 7 | import Foundation 8 | 9 | public enum AudioServiceError: LocalizedError { 10 | /// Indicates that the specified sound file could not be found. 11 | case fileNotFound 12 | /// Indicates that the audio player could not be initialized. 13 | case initializationFailed 14 | 15 | public var errorDescription: String? { 16 | switch self { 17 | case .fileNotFound: 18 | return "Failed to find sound file!" 19 | case .initializationFailed: 20 | return "Failed to initialize audio player!" 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Services/Sources/AudioService/AudioServiceProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AudioServiceProtocol.swift 3 | // 4 | // Created by James Sedlacek on 2/18/25. 5 | // 6 | 7 | import Foundation 8 | 9 | /// Protocol defining the interface for audio playback services. 10 | public protocol AudioServiceProtocol: Sendable { 11 | /// Plays the specified sound file. 12 | /// - Parameter file: The sound file to play. 13 | /// - Throws: AudioServiceError if the sound file cannot be found or played. 14 | func play(_ file: SoundFile) throws(AudioServiceError) 15 | } 16 | -------------------------------------------------------------------------------- /Services/Sources/AudioService/EnvironmentValues+AudioService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EnvironmentValues+AudioService.swift 3 | // 4 | // Created by James Sedlacek on 2/18/25. 5 | // 6 | 7 | import SwiftUI 8 | 9 | private struct AudioServiceKey: EnvironmentKey { 10 | static let defaultValue: any AudioServiceProtocol = AudioService() 11 | } 12 | 13 | /// Provides environment access to the audio service. 14 | extension EnvironmentValues { 15 | /// The audio service instance available in the environment. 16 | public var audioService: AudioServiceProtocol { 17 | get { self[AudioServiceKey.self] } 18 | set { self[AudioServiceKey.self] = newValue } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Services/Sources/AudioService/MockAudioService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockAudioService.swift 3 | // 4 | // Created by James Sedlacek on 2/17/25. 5 | // 6 | 7 | import Foundation 8 | 9 | public class MockAudioService: @unchecked Sendable { 10 | private(set) var playedSounds: [SoundFile] = [] 11 | private var shouldThrow: Bool = false 12 | 13 | public init(shouldThrow: Bool = false) { 14 | self.shouldThrow = shouldThrow 15 | } 16 | } 17 | 18 | extension MockAudioService: AudioServiceProtocol { 19 | public func play(_ file: SoundFile) throws(AudioServiceError) { 20 | if shouldThrow { 21 | throw .fileNotFound 22 | } 23 | playedSounds.append(file) 24 | } 25 | 26 | public func reset() { 27 | playedSounds.removeAll() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Services/Sources/AudioService/Resources/ding.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JamesSedlacek/Scrumdinger/d575833721b0428ab407ceef10a7d43cffb7ad9f/Services/Sources/AudioService/Resources/ding.wav -------------------------------------------------------------------------------- /Services/Sources/AudioService/SoundFile.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SoundFile.swift 3 | // 4 | // Created by James Sedlacek on 2/18/25. 5 | // 6 | 7 | import Foundation 8 | 9 | /// Represents the available sound files in the application. 10 | /// Each case corresponds to a specific audio file resource. 11 | public enum SoundFile: String, CaseIterable, Identifiable { 12 | case ding 13 | 14 | public var id: Self { self } 15 | 16 | var url: URL? { 17 | Bundle.module.url( 18 | forResource: rawValue, 19 | withExtension: "wav" 20 | ) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Services/Sources/FileService/EnvironmentValues+FileService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EnvironmentValues+FileService.swift 3 | // 4 | // Created by James Sedlacek on 2/22/25. 5 | // 6 | 7 | import SwiftUI 8 | 9 | private struct FileServiceKey: EnvironmentKey { 10 | static let defaultValue: FileServiceProtocol = FileService() 11 | } 12 | 13 | /// Provides environment access to the file service. 14 | extension EnvironmentValues { 15 | /// The file service instance available in the environment. 16 | public var fileService: FileServiceProtocol { 17 | get { self[FileServiceKey.self] } 18 | set { self[FileServiceKey.self] = newValue } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Services/Sources/FileService/FileService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileService.swift 3 | // 4 | // Created by James Sedlacek on 2/20/25. 5 | // 6 | 7 | import Foundation 8 | 9 | /// A service that persists data to the file system using JSON encoding. 10 | /// - Provides methods to save and load arrays of Codable objects. 11 | /// - Uses the device's document directory for storage. 12 | /// - Handles various error cases through `FileServiceError`. 13 | public struct FileService: FileServiceProtocol, @unchecked Sendable { 14 | private let fileManager: FileManager 15 | private let encoder: JSONEncoder = .init() 16 | private let decoder: JSONDecoder = .init() 17 | 18 | /// Creates a new FileService instance. 19 | /// - Parameter fileManager: The file manager to use for storage operations. 20 | /// Defaults to FileManager.default. 21 | public init(fileManager: FileManager = .default) { 22 | print("Initializing FileService...") 23 | self.fileManager = fileManager 24 | } 25 | 26 | /// Creates a URL for the specified key in the app's document directory. 27 | /// - Parameter key: The key to use for the file name. 28 | /// - Returns: A URL if the document directory is available, nil otherwise. 29 | private func url(forKey key: String) -> URL? { 30 | fileManager.urls( 31 | for: .documentDirectory, 32 | in: .userDomainMask 33 | ).first?.appendingPathComponent(key) 34 | } 35 | 36 | /// Saves an array of encodable objects to a file. 37 | /// - Parameters: 38 | /// - objects: The array of objects to save. 39 | /// - key: The key to use for the file name. 40 | /// - Throws: A `FileServiceError` describing what went wrong if the operation fails. 41 | public func save(_ objects: [T], forKey key: String) throws(FileServiceError) { 42 | guard let url = url(forKey: key) else { 43 | throw .invalidURL 44 | } 45 | 46 | do { 47 | let data = try encoder.encode(objects) 48 | try data.write(to: url) 49 | } catch let error as EncodingError { 50 | throw .encodingFailed(error) 51 | } catch { 52 | throw .fileWriteFailed(error) 53 | } 54 | } 55 | 56 | /// Loads an array of decodable objects from a file. 57 | /// - Parameter key: The key used to save the file. 58 | /// - Returns: An array of decoded objects. 59 | /// - Throws: A `FileServiceError` describing what went wrong if the operation fails. 60 | public func load(forKey key: String) throws(FileServiceError) -> [T] { 61 | guard let url = url(forKey: key) else { 62 | throw .invalidURL 63 | } 64 | 65 | do { 66 | let data = try Data(contentsOf: url) 67 | let objects = try decoder.decode([T].self, from: data) 68 | return objects 69 | } catch let error as DecodingError { 70 | throw .decodingFailed(error) 71 | } catch { 72 | throw .fileReadFailed(error) 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Services/Sources/FileService/FileServiceError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileServiceError.swift 3 | // 4 | // Created by James Sedlacek on 2/22/25. 5 | // 6 | 7 | import Foundation 8 | 9 | /// Error cases that can occur during file service operations. 10 | /// - All errors conform to `LocalizedError` to provide human-readable descriptions. 11 | public enum FileServiceError: LocalizedError { 12 | /// The URL for the specified key could not be created. 13 | case invalidURL 14 | /// An error occurred while encoding the data to JSON. 15 | case encodingFailed(Error) 16 | /// An error occurred while decoding the data from JSON. 17 | case decodingFailed(Error) 18 | /// An error occurred while writing data to the file. 19 | case fileWriteFailed(Error) 20 | /// An error occurred while reading data from the file. 21 | case fileReadFailed(Error) 22 | 23 | public var errorDescription: String? { 24 | switch self { 25 | case .invalidURL: 26 | return "Invalid URL" 27 | case .encodingFailed(let error): 28 | return "Failed to encode data: \(error.localizedDescription)" 29 | case .decodingFailed(let error): 30 | return "Failed to decode data: \(error.localizedDescription)" 31 | case .fileWriteFailed(let error): 32 | return "Failed to write to file: \(error.localizedDescription)" 33 | case .fileReadFailed(let error): 34 | return "Failed to read from file: \(error.localizedDescription)" 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Services/Sources/FileService/FileServiceProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileServiceProtocol.swift 3 | // 4 | // Created by James Sedlacek on 2/22/25. 5 | // 6 | 7 | import Foundation 8 | 9 | public protocol FileServiceProtocol: Sendable { 10 | /// Saves an array of encodable objects to a file. 11 | /// - Parameters: 12 | /// - objects: The array of objects to save. 13 | /// - key: The key to use for the file name. 14 | /// - Throws: A `FileServiceError` describing what went wrong if the operation fails. 15 | func save(_ objects: [T], forKey key: String) throws(FileServiceError) 16 | 17 | /// Loads an array of decodable objects from a file. 18 | /// - Parameter key: The key used to save the file. 19 | /// - Returns: An array of decoded objects. 20 | /// - Throws: A `FileServiceError` describing what went wrong if the operation fails. 21 | func load(forKey key: String) throws(FileServiceError) -> [T] 22 | } 23 | -------------------------------------------------------------------------------- /Services/Sources/FileService/MockFileService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockFileService.swift 3 | // 4 | // Created by James Sedlacek on 2/22/25. 5 | // 6 | 7 | import Foundation 8 | 9 | public class MockFileService: @unchecked Sendable { 10 | private var storage: [String: Any] = [:] 11 | private var shouldThrow: Bool 12 | 13 | public init(shouldThrow: Bool = false) { 14 | self.shouldThrow = shouldThrow 15 | } 16 | } 17 | 18 | extension MockFileService: FileServiceProtocol { 19 | public func save( 20 | _ objects: [T], 21 | forKey key: String 22 | ) throws(FileServiceError) { 23 | if shouldThrow { 24 | throw .fileWriteFailed(NSError(domain: "", code: -1)) 25 | } 26 | storage[key] = objects 27 | } 28 | 29 | public func load( 30 | forKey key: String 31 | ) throws(FileServiceError) -> [T] { 32 | if shouldThrow { 33 | throw .fileReadFailed(NSError(domain: "", code: -1)) 34 | } 35 | guard let objects = storage[key] as? [T] else { 36 | throw .decodingFailed(NSError(domain: "", code: -1)) 37 | } 38 | return objects 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Services/Sources/SpeechService/EnvironmentValues+SpeechService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EnvironmentValues+SpeechService.swift 3 | // 4 | // Created by James Sedlacek on 2/22/25. 5 | // 6 | 7 | import SwiftUI 8 | 9 | private struct SpeechServiceKey: EnvironmentKey { 10 | static let defaultValue: any SpeechServiceProtocol = SpeechService() 11 | } 12 | 13 | /// Provides environment access to the speech service. 14 | extension EnvironmentValues { 15 | /// The speech service instance available in the environment. 16 | public var speechService: any SpeechServiceProtocol { 17 | get { self[SpeechServiceKey.self] } 18 | set { self[SpeechServiceKey.self] = newValue } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Services/Sources/SpeechService/MockSpeechService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockSpeechService.swift 3 | // 4 | // Created by James Sedlacek on 2/21/25. 5 | // 6 | 7 | import Foundation 8 | 9 | public final class MockSpeechService: SpeechServiceProtocol, @unchecked Sendable { 10 | private var accumulatedText: String = "" 11 | private var continuationHandler: AsyncThrowingStream.Continuation? 12 | private var shouldSimulateError: Bool 13 | private var simulatedTexts: [String] 14 | private var currentTextIndex: Int = 0 15 | 16 | public init( 17 | shouldSimulateError: Bool = false, 18 | simulatedTexts: [String] = ["Hello", "This is a test", "Final transcript"] 19 | ) { 20 | self.shouldSimulateError = shouldSimulateError 21 | self.simulatedTexts = simulatedTexts 22 | } 23 | 24 | @MainActor 25 | public func requestPermissions() async throws(SpeechServiceError) { 26 | if shouldSimulateError { 27 | throw .notAuthorizedToRecognize 28 | } 29 | } 30 | 31 | @MainActor 32 | public func startRecording() throws(SpeechServiceError) -> AsyncThrowingStream { 33 | if shouldSimulateError { 34 | throw .recognizerUnavailable 35 | } 36 | 37 | return AsyncThrowingStream { continuation in 38 | self.continuationHandler = continuation 39 | 40 | // Simulate speech recognition with a timer 41 | Task { 42 | for text in simulatedTexts { 43 | try? await Task.sleep(for: .seconds(1)) 44 | guard !Task.isCancelled else { break } 45 | accumulatedText = text 46 | continuation.yield(text) 47 | } 48 | continuation.finish() 49 | } 50 | } 51 | } 52 | 53 | public func stopRecording() { 54 | continuationHandler?.finish() 55 | continuationHandler = nil 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Services/Sources/SpeechService/Permissions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Permissions.swift 3 | // 4 | // Created by James Sedlacek on 2/19/25. 5 | // 6 | 7 | import AVFoundation 8 | import Foundation 9 | import Speech 10 | 11 | extension SFSpeechRecognizer { 12 | static func hasAuthorizationToRecognize() async -> Bool { 13 | await withCheckedContinuation { continuation in 14 | requestAuthorization { status in 15 | continuation.resume(returning: status == .authorized) 16 | } 17 | } 18 | } 19 | } 20 | 21 | extension AVAudioSession { 22 | func hasPermissionToRecord() async -> Bool { 23 | await withCheckedContinuation { continuation in 24 | AVAudioApplication.requestRecordPermission { authorized in 25 | continuation.resume(returning: authorized) 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Services/Sources/SpeechService/SpeechService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SpeechService.swift 3 | // 4 | // Created by James Sedlacek on 2/17/25. 5 | // 6 | 7 | import AVFoundation 8 | import Foundation 9 | import Speech 10 | import SwiftUI 11 | 12 | /// A service that manages speech recognition using SFSpeechRecognizer. 13 | public final class SpeechService: SpeechServiceProtocol, @unchecked Sendable { 14 | private var accumulatedText: String = "" 15 | private var audioEngine: AVAudioEngine? 16 | private var request: SFSpeechAudioBufferRecognitionRequest? 17 | private var task: SFSpeechRecognitionTask? 18 | private let recognizer: SFSpeechRecognizer? 19 | private var continuationHandler: AsyncThrowingStream.Continuation? 20 | 21 | public init(localeIdentifier: String = Locale.current.identifier) { 22 | print("🎤 Initializing SpeechService...") 23 | print("📍 Using locale: \(localeIdentifier)") 24 | recognizer = SFSpeechRecognizer(locale: Locale(identifier: localeIdentifier)) 25 | print(recognizer != nil ? "✅ SpeechRecognizer created" : "❌ Failed to create SpeechRecognizer") 26 | } 27 | 28 | /// Request necessary permissions for speech recognition. 29 | @MainActor public func requestPermissions() async throws(SpeechServiceError) { 30 | print("🔐 Requesting speech recognition permissions...") 31 | 32 | guard let recognizer else { 33 | print("❌ SpeechRecognizer not initialized") 34 | throw .initializationFailed 35 | } 36 | 37 | print("🎤 Checking speech recognition authorization...") 38 | let authStatus = await SFSpeechRecognizer.hasAuthorizationToRecognize() 39 | print("📊 Authorization status: \(authStatus)") 40 | guard authStatus else { 41 | print("❌ Not authorized to recognize speech") 42 | throw .notAuthorizedToRecognize 43 | } 44 | print("✅ Speech recognition authorized") 45 | 46 | print("🎙️ Checking microphone permission...") 47 | let recordPermission = await AVAudioSession.sharedInstance().hasPermissionToRecord() 48 | print("📊 Record permission: \(recordPermission)") 49 | guard recordPermission else { 50 | print("❌ Not permitted to record audio") 51 | throw .notPermittedToRecord 52 | } 53 | print("✅ Microphone access granted") 54 | 55 | print("🔍 Checking recognizer availability...") 56 | print("📊 Recognizer available: \(recognizer.isAvailable)") 57 | guard recognizer.isAvailable else { 58 | print("❌ Speech recognizer unavailable") 59 | throw .recognizerUnavailable 60 | } 61 | } 62 | 63 | /// Start recording and transcribing speech. 64 | @MainActor public func startRecording() throws(SpeechServiceError) -> AsyncThrowingStream { 65 | print("🎤 Starting speech recording...") 66 | 67 | guard let recognizer, recognizer.isAvailable else { 68 | print("❌ Speech recognizer unavailable") 69 | throw .recognizerUnavailable 70 | } 71 | 72 | print("🔄 Stopping any existing recording...") 73 | stopRecording() 74 | 75 | print("🌊 Creating transcription stream...") 76 | return createTranscriptionStream() 77 | } 78 | 79 | @MainActor 80 | private func createTranscriptionStream() -> AsyncThrowingStream { 81 | print("🔄 Creating transcription stream...") 82 | return AsyncThrowingStream { continuation in 83 | print("📝 Setting up continuation handler...") 84 | self.continuationHandler = continuation 85 | 86 | Task { 87 | do { 88 | print("⚙️ Configuring audio session...") 89 | let (engine, request) = try Self.configureAudioSession() 90 | self.audioEngine = engine 91 | self.request = request 92 | 93 | print("🎯 Creating recognition task...") 94 | task = recognizer?.recognitionTask(with: request) { [weak self] result, error in 95 | print("📍 Recognition callback - hasResult: \(result != nil), hasError: \(error != nil)") 96 | guard let self else { 97 | print("⚠️ Self is nil in recognition callback") 98 | return 99 | } 100 | 101 | if let error { 102 | print("❌ Recognition error: \(error)") 103 | continuation.finish(throwing: error) 104 | print("🛑 Stopping recording due to error") 105 | self.stopRecording() 106 | return 107 | } 108 | 109 | if let result { 110 | let newText = result.bestTranscription.formattedString 111 | print("📝 New transcription: \(newText)") 112 | continuation.yield(self.accumulatedText + newText) 113 | 114 | if result.isFinal { 115 | print("✅ Final result received") 116 | self.accumulatedText += newText + " " 117 | print("🔄 Stopping recording after final result") 118 | self.stopRecording() 119 | } 120 | } 121 | } 122 | 123 | print("✅ Recording started successfully") 124 | 125 | } catch { 126 | print("❌ Stream setup error: \(error)") 127 | continuation.finish(throwing: error) 128 | print("🛑 Stopping recording due to setup error") 129 | self.stopRecording() 130 | } 131 | } 132 | } 133 | } 134 | 135 | private static func configureAudioSession() throws -> (AVAudioEngine, SFSpeechAudioBufferRecognitionRequest) { 136 | print("🔄 Setting up audio session...") 137 | let audioEngine = AVAudioEngine() 138 | let request = SFSpeechAudioBufferRecognitionRequest() 139 | request.shouldReportPartialResults = true 140 | request.taskHint = .dictation 141 | request.addsPunctuation = true 142 | 143 | print("⚙️ Configuring audio session...") 144 | let audioSession = AVAudioSession.sharedInstance() 145 | do { 146 | print("🔊 Setting audio session category...") 147 | try audioSession.setCategory(.playAndRecord, mode: .measurement, options: .duckOthers) 148 | print("🔊 Activating audio session...") 149 | try audioSession.setActive(true, options: .notifyOthersOnDeactivation) 150 | } catch { 151 | print("❌ Audio session configuration error: \(error)") 152 | throw error 153 | } 154 | 155 | print("🎙️ Setting up audio input...") 156 | let inputNode = audioEngine.inputNode 157 | let recordingFormat = inputNode.outputFormat(forBus: 0) 158 | print("📊 Recording format: \(recordingFormat)") 159 | 160 | print("📝 Installing audio tap...") 161 | inputNode.installTap( 162 | onBus: 0, 163 | bufferSize: 1024, 164 | format: recordingFormat 165 | ) { [weak request] buffer, _ in 166 | print("🔄 Processing audio buffer...") 167 | request?.append(buffer) 168 | } 169 | 170 | print("🔄 Starting audio engine...") 171 | audioEngine.prepare() 172 | do { 173 | try audioEngine.start() 174 | print("✅ Audio engine started successfully") 175 | } catch { 176 | print("❌ Audio engine start error: \(error)") 177 | throw error 178 | } 179 | 180 | return (audioEngine, request) 181 | } 182 | 183 | /// Stop recording and transcribing speech. 184 | public func stopRecording() { 185 | print("🛑 Stopping speech recording...") 186 | 187 | if let task = task { 188 | print("🔄 Cancelling recognition task...") 189 | task.cancel() 190 | self.task = nil 191 | } 192 | 193 | if let engine = audioEngine { 194 | print("🔄 Stopping audio engine...") 195 | engine.stop() 196 | engine.inputNode.removeTap(onBus: 0) 197 | self.audioEngine = nil 198 | } 199 | 200 | print("🧹 Cleaning up request...") 201 | request = nil 202 | 203 | if let handler = continuationHandler { 204 | print("🔄 Finishing continuation handler...") 205 | handler.finish() 206 | continuationHandler = nil 207 | } 208 | 209 | print("🔊 Deactivating audio session...") 210 | do { 211 | try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) 212 | } catch { 213 | print("⚠️ Error deactivating audio session: \(error)") 214 | } 215 | 216 | print("✅ Recording stopped successfully") 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /Services/Sources/SpeechService/SpeechServiceError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SpeechServiceError.swift 3 | // 4 | // Created by James Sedlacek on 2/19/25. 5 | // 6 | 7 | import Foundation 8 | 9 | /// Errors that can occur during speech recognition operations. 10 | public enum SpeechServiceError: LocalizedError { 11 | case initializationFailed 12 | case notAuthorizedToRecognize 13 | case notPermittedToRecord 14 | case recognizerUnavailable 15 | case streamFailed(Error?) 16 | 17 | public var errorDescription: String? { 18 | switch self { 19 | case .initializationFailed: 20 | return "Failed to initialize speech recognizer" 21 | case .notAuthorizedToRecognize: 22 | return "Not authorized to recognize speech" 23 | case .notPermittedToRecord: 24 | return "Not permitted to record audio" 25 | case .recognizerUnavailable: 26 | return "Speech recognizer is unavailable" 27 | case .streamFailed(let error): 28 | return "Speech recognition stream failed: \(error?.localizedDescription ?? "Unknown error")" 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Services/Sources/SpeechService/SpeechServiceProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SpeechServiceProtocol.swift 3 | // 4 | // Created by James Sedlacek on 2/21/25. 5 | // 6 | 7 | import Foundation 8 | 9 | public protocol SpeechServiceProtocol: AnyObject, Sendable { 10 | @MainActor func requestPermissions() async throws(SpeechServiceError) 11 | @MainActor func startRecording() throws(SpeechServiceError) -> AsyncThrowingStream 12 | func stopRecording() 13 | } 14 | -------------------------------------------------------------------------------- /UITests.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "D35C15D7-58B4-4FA4-AB21-17094B8D2C28", 5 | "name" : "Configuration 1", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | "testTimeoutsEnabled" : true 13 | }, 14 | "testTargets" : [ 15 | 16 | ], 17 | "version" : 1 18 | } 19 | -------------------------------------------------------------------------------- /ViewComponents/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /ViewComponents/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "ViewComponents", 8 | defaultLocalization: "en", 9 | platforms: [.iOS(.v17), .macOS(.v14)], 10 | products: PackageProduct.allCases.map(\.description), 11 | targets: [ 12 | InternalTarget.allCases.map(\.target) 13 | ].flatMap { $0 } 14 | ) 15 | 16 | // MARK: PackageProduct 17 | 18 | private enum PackageProduct: CaseIterable { 19 | case viewComponents 20 | 21 | var name: String { 22 | switch self { 23 | case .viewComponents: "ViewComponents" 24 | } 25 | } 26 | 27 | var targets: [InternalTarget] { 28 | switch self { 29 | case .viewComponents: 30 | InternalTarget.allCases 31 | } 32 | } 33 | 34 | var description: PackageDescription.Product { 35 | switch self { 36 | case .viewComponents: 37 | .library( 38 | name: self.name, 39 | targets: self.targets.map(\.title) 40 | ) 41 | } 42 | } 43 | } 44 | 45 | // MARK: InternalTarget 46 | 47 | private enum InternalTarget: CaseIterable { 48 | case viewComponents 49 | 50 | var title: String { 51 | switch self { 52 | case .viewComponents: return "ViewComponents" 53 | } 54 | } 55 | 56 | var targetDependency: Target.Dependency { 57 | .target(name: title) 58 | } 59 | 60 | var dependencies: [Target.Dependency] { [] } 61 | 62 | var target: Target { 63 | .target( 64 | name: self.title, 65 | dependencies: self.dependencies 66 | ) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /ViewComponents/Sources/ViewComponents/Buttons/ToolbarButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToolbarButton.swift 3 | // 4 | // Created by James Sedlacek on 2/7/25. 5 | // 6 | 7 | import SwiftUI 8 | 9 | @MainActor 10 | public struct ToolbarButton: ToolbarContent { 11 | private let title: LocalizedStringKey 12 | private let placement: ToolbarItemPlacement 13 | private let action: () -> Void 14 | 15 | public init( 16 | _ title: LocalizedStringKey, 17 | placement: ToolbarItemPlacement = .confirmationAction, 18 | perform action: @escaping () -> Void 19 | ) { 20 | self.title = title 21 | self.placement = placement 22 | self.action = action 23 | } 24 | 25 | public var body: some ToolbarContent { 26 | ToolbarItem(placement: placement) { 27 | Button(action: action, label: labelView) 28 | } 29 | } 30 | 31 | private func labelView() -> some View { 32 | Text(title) 33 | .font(.system(size: 16, weight: .medium)) 34 | .foregroundStyle(Color.blue) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /ViewComponents/Sources/ViewComponents/Buttons/ToolbarIconButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToolbarIconButton.swift 3 | // 4 | // Created by James Sedlacek on 2/7/25. 5 | // 6 | 7 | import SwiftUI 8 | 9 | @MainActor 10 | public struct ToolbarIconButton: ToolbarContent { 11 | private let systemName: String 12 | private let placement: ToolbarItemPlacement 13 | private let action: () -> Void 14 | 15 | public init( 16 | systemName: String, 17 | placement: ToolbarItemPlacement = .confirmationAction, 18 | perform action: @escaping () -> Void 19 | ) { 20 | self.systemName = systemName 21 | self.placement = placement 22 | self.action = action 23 | } 24 | 25 | public var body: some ToolbarContent { 26 | ToolbarItem(placement: placement) { 27 | Button(action: action, label: labelView) 28 | } 29 | } 30 | 31 | private func labelView() -> some View { 32 | Image(systemName: systemName) 33 | .font(.system(size: 16, weight: .medium)) 34 | .foregroundStyle(Color.blue) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /ViewComponents/Sources/ViewComponents/Labels/TrailingIconLabelStyle.swift: -------------------------------------------------------------------------------- 1 | /* 2 | See LICENSE folder for this sample’s licensing information. 3 | */ 4 | 5 | import SwiftUI 6 | 7 | public struct TrailingIconLabelStyle: LabelStyle { 8 | public func makeBody(configuration: Configuration) -> some View { 9 | HStack { 10 | configuration.title 11 | configuration.icon 12 | } 13 | } 14 | } 15 | 16 | extension LabelStyle where Self == TrailingIconLabelStyle { 17 | public static var trailingIcon: Self { Self() } 18 | } 19 | -------------------------------------------------------------------------------- /ViewComponents/Sources/ViewComponents/ProgressViews/RoundedProgressViewStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RoundedProgressViewStyle.swift 3 | // 4 | // Created by James Sedlacek on 2/17/25. 5 | // 6 | 7 | import SwiftUI 8 | 9 | public struct RoundedProgressViewStyle: ProgressViewStyle { 10 | private let backgroundColor: Color 11 | 12 | public init( 13 | background: Color 14 | ) { 15 | self.backgroundColor = background 16 | } 17 | 18 | public func makeBody(configuration: Configuration) -> some View { 19 | ProgressView(configuration) 20 | .frame(height: 12.0) 21 | .padding(.horizontal) 22 | .background( 23 | backgroundColor, 24 | in: .rect(cornerRadius: 10.0) 25 | ) 26 | .frame(height: 20.0) 27 | } 28 | } 29 | 30 | extension ProgressViewStyle where Self == RoundedProgressViewStyle { 31 | public static func rounded( 32 | background: Color 33 | ) -> Self { 34 | Self(background: background) 35 | } 36 | } 37 | 38 | #Preview { 39 | ProgressView(value: 0.4) 40 | .tint(.blue) 41 | .progressViewStyle( 42 | .rounded( 43 | background: .gray.opacity(0.3) 44 | ) 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /scripts/install-hooks.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "📎 Installing pre-commit hook..." 4 | cp .githooks/pre-commit .git/hooks/pre-commit 5 | chmod +x .git/hooks/pre-commit 6 | echo "✅ Done! Git pre-commit hook installed." --------------------------------------------------------------------------------