diff --git a/.github/workflows/upload-to-testflight.yml b/.github/workflows/publish_ios_app.yml similarity index 76% rename from .github/workflows/upload-to-testflight.yml rename to .github/workflows/publish_ios_app.yml index 3d2dd51..8ad540c 100644 --- a/.github/workflows/upload-to-testflight.yml +++ b/.github/workflows/publish_ios_app.yml @@ -1,14 +1,18 @@ -name: Upload to TestFlight +name: Publish iOS App on: push: branches: - main + paths: + - Shared/** + - iOS/** env: - DEVELOPER_DIR: /Applications/Xcode_16.2.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_26.5.app/Contents/Developer jobs: - upload: - runs-on: macos-15-large + make: + if: false + runs-on: macos-26-large steps: - uses: actions/checkout@v4 with: @@ -17,17 +21,17 @@ jobs: - name: Load cached sound font id: load-cached-sound-font - uses: actions/cache@v4 + uses: actions/cache@v6 with: key: sound_font - path: ./Bemol/Resources/sound_font.sf2 + path: ./Shared/Resources/sound_font.sf2 - if: ${{ steps.load-cached-sound-font.outputs.cache-hit != 'true' }} name: Download sound font env: SOUND_FONT_URL: ${{ secrets.SOUND_FONT_URL }} run: | - curl -L -o ./Bemol/Resources/sound_font.sf2 ${SOUND_FONT_URL} + curl -L -o ./Shared/Resources/sound_font.sf2 ${SOUND_FONT_URL} - name: Set up the development team env: @@ -41,20 +45,20 @@ jobs: APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }} APP_STORE_CONNECT_API_KEY_CONTENTS: ${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENTS }} run: | - mkdir ./private_keys + mkdir ./iOS/private_keys echo -e "$APP_STORE_CONNECT_API_KEY_CONTENTS" > ./private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY}.p8 - name: Install the Apple certificates # https://docs.github.com/en/actions/deployment/deploying-xcode-applications/installing-an-apple-certificate-on-macos-runners-for-xcode-development env: - DEVELOPMENT_CERTIFICATE: ${{ secrets.DEVELOPMENT_CERTIFICATE }} - DEVELOPMENT_CERTIFICATE_PASSPHRASE: ${{ secrets.DEVELOPMENT_CERTIFICATE_PASSPHRASE }} - DISTRIBUTION_CERTIFICATE: ${{ secrets.DISTRIBUTION_CERTIFICATE }} - DISTRIBUTION_CERTIFICATE_PASSPHRASE: ${{ secrets.DISTRIBUTION_CERTIFICATE_PASSPHRASE }} + DEVELOPMENT_CERTIFICATE: ${{ secrets.IOS_DEVELOPMENT_CERTIFICATE }} + DEVELOPMENT_CERTIFICATE_PASSPHRASE: ${{ secrets.IOS_DEVELOPMENT_CERTIFICATE_PASSPHRASE }} + DISTRIBUTION_CERTIFICATE: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE }} + DISTRIBUTION_CERTIFICATE_PASSPHRASE: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_PASSPHRASE }} KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} run: | - development_certificate=$RUNNER_TEMP/BemolDevelopmentCertificate.p12 - distribution_certificate=$RUNNER_TEMP/BemolDistributionCertificate.p12 + development_certificate=$RUNNER_TEMP/BemolIOSDevelopmentCertificate.p12 + distribution_certificate=$RUNNER_TEMP/BemolIOSDistributionCertificate.p12 keychain_path=$RUNNER_TEMP/certificates.keychain-db echo -n "$DEVELOPMENT_CERTIFICATE" | base64 --decode -o $development_certificate @@ -78,10 +82,10 @@ jobs: run: | chmod +x ./Scripts/next_marketing_version.sh chmod +x ./Scripts/next_build_version.sh - chmod +x ./Scripts/upload_to_testflight.sh + chmod +x ./Scripts/make_ios_app.sh marketing_version=`./Scripts/next_marketing_version.sh` build_version=`./Scripts/next_build_version.sh` - ./Scripts/upload_to_testflight.sh $marketing_version $build_version + ./Scripts/make_ios_app.sh $marketing_version $build_version echo "TAG_NAME=$marketing_version" >> "$GITHUB_OUTPUT" - name: Create tag diff --git a/.github/workflows/publish_macos_app.yml b/.github/workflows/publish_macos_app.yml new file mode 100644 index 0000000..ecc4de7 --- /dev/null +++ b/.github/workflows/publish_macos_app.yml @@ -0,0 +1,110 @@ +name: Publish macOS App +on: + push: + branches: + - main + paths: + - Shared/** + - macOS/** +env: + DEVELOPER_DIR: /Applications/Xcode_26.5.app/Contents/Developer + +jobs: + make: + runs-on: macos-26-large + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: main + + - name: Load cached sound font + id: load-cached-sound-font + uses: actions/cache@v6 + with: + key: sound_font + path: ./Shared/Resources/sound_font.sf2 + + - if: ${{ steps.load-cached-sound-font.outputs.cache-hit != 'true' }} + name: Download sound font + env: + SOUND_FONT_URL: ${{ secrets.SOUND_FONT_URL }} + run: | + curl -L -o ./Shared/Resources/sound_font.sf2 ${SOUND_FONT_URL} + + - name: Set up code signing + env: + DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }} + run: | + configuration="CODE_SIGN_IDENTITY = Developer ID Application;\nDEVELOPMENT_TEAM = ${DEVELOPMENT_TEAM};\nCODE_SIGN_STYLE = Manual;\n" + echo -e "$configuration" > ./Configurations/Signing.xcconfig + + - name: Install the App Store Connect API key + env: + APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }} + APP_STORE_CONNECT_API_KEY_CONTENTS: ${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENTS }} + run: | + mkdir ./macOS/private_keys + echo -e "$APP_STORE_CONNECT_API_KEY_CONTENTS" > ./private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY}.p8 + + - name: Install the Apple certificates + # https://docs.github.com/en/actions/deployment/deploying-xcode-applications/installing-an-apple-certificate-on-macos-runners-for-xcode-development + env: + DISTRIBUTION_CERTIFICATE: ${{ secrets.MACOS_DEVELOPER_ID_APPLICATION_CERTIFICATE }} + DISTRIBUTION_CERTIFICATE_PASSPHRASE: ${{ secrets.MACOS_DEVELOPER_ID_APPLICATION_CERTIFICATE_PASSPHRASE }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + distribution_certificate=$RUNNER_TEMP/BemolDeveloperIDApplicationCertificate.p12 + keychain_path=$RUNNER_TEMP/certificates.keychain-db + + echo -n "$DISTRIBUTION_CERTIFICATE" | base64 --decode -o $distribution_certificate + + security create-keychain -p "$KEYCHAIN_PASSWORD" $keychain_path + security set-keychain-settings -lut 21600 $keychain_path + security unlock-keychain -p "$KEYCHAIN_PASSWORD" $keychain_path + + security import $distribution_certificate -P "$DISTRIBUTION_CERTIFICATE_PASSPHRASE" -A -t cert -f pkcs12 -k $keychain_path + + security list-keychain -d user -s $keychain_path + + - name: Archive and notarize + id: archive-and-notarize + env: + APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }} + APP_STORE_CONNECT_API_ISSUER: ${{ secrets.APP_STORE_CONNECT_API_ISSUER }} + run: | + chmod +x ./Scripts/next_marketing_version.sh + chmod +x ./Scripts/next_build_version.sh + chmod +x ./Scripts/make_macos_app.sh + marketing_version=`./Scripts/next_marketing_version.sh` + build_version=`./Scripts/next_build_version.sh` + ./Scripts/make_macos_app.sh $marketing_version $build_version + echo "TAG_NAME=$marketing_version" >> "$GITHUB_OUTPUT" + + - name: Upload + id: upload + uses: actions/upload-artifact@v7 + with: + name: "Bemol-${{ steps.archive-and-notarize.outputs.TAG_NAME }}" + path: | + ./Artifacts/macOS/Bemol.zip + ./Artifacts/macOS/NotarizationResponse.plist + if-no-files-found: error + overwrite: true + + - name: Create tag + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} + script: | + github.rest.git.createRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `refs/tags/${{ steps.archive-and-notarize.outputs.TAG_NAME }}`, + sha: context.sha + }) + + - name: Clean up + run: | + rm -rf ./macOS/private_keys + security delete-keychain $RUNNER_TEMP/certificates.keychain-db diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index fc028fc..02de460 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -2,12 +2,14 @@ name: Test on: pull_request: branches: [ main, develop ] + paths: + - Shared/** env: - DEVELOPER_DIR: /Applications/Xcode_16.2.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_26.5.app/Contents/Developer jobs: test: - runs-on: macos-15-large + runs-on: macos-26-large steps: - uses: actions/checkout@v4 @@ -16,7 +18,21 @@ jobs: DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }} run: | configuration="CODE_SIGN_STYLE = Automatic\nDEVELOPMENT_TEAM = ${DEVELOPMENT_TEAM}\n" - echo -e "$configuration" > ./Configurations/Signing.xcconfig + echo -e "$configuration" > ./macOS/Configurations/Signing.xcconfig + + - name: Load cached sound font + id: load-cached-sound-font + uses: actions/cache@v6 + with: + key: sound_font + path: ./Shared/Resources/sound_font.sf2 + + - if: ${{ steps.load-cached-sound-font.outputs.cache-hit != 'true' }} + name: Download sound font + env: + SOUND_FONT_URL: ${{ secrets.SOUND_FONT_URL }} + run: | + curl -L -o ./Shared/Resources/sound_font.sf2 ${SOUND_FONT_URL} - name: Run tests run: | @@ -24,7 +40,7 @@ jobs: ./Scripts/test.sh - name: Publish test results - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: test-results.xcresult - path: ./Artifacts/test-results.xcresult + path: ./Artifacts/Tests/test-results.xcresult diff --git a/.gitignore b/.gitignore index 42eb7a6..ca04675 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,10 @@ */xcshareddata/IDEWorkspaceChecks.plist xcuserdata/ Artifacts/ -private_keys/ -Certificates/ -Configurations/Signing.xcconfig -Bemol/Resources/sound_font.sf2 -Screenshots/ +iOS/private_keys/ +iOS/Certificates/ +iOS/Configurations/Signing.xcconfig +macOS/private_keys/ +macOS/Certificates/ +macOS/Configurations/Signing.xcconfig +Shared/Resources/sound_font.sf2 diff --git a/Bemol.xcodeproj/project.pbxproj b/Bemol.xcodeproj/project.pbxproj index 0f39690..35ed206 100644 --- a/Bemol.xcodeproj/project.pbxproj +++ b/Bemol.xcodeproj/project.pbxproj @@ -6,97 +6,269 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + 3215DAE62FE9E1EA00B94FCD /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 32A9AE642DC11956000B7EDE /* PrivacyInfo.xcprivacy */; }; + 32617A9B2FEAFC5B006F8BFB /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 32A9AE642DC11956000B7EDE /* PrivacyInfo.xcprivacy */; }; +/* End PBXBuildFile section */ + /* Begin PBXContainerItemProxy section */ - 32A9AD082DBA7B03000B7EDE /* PBXContainerItemProxy */ = { + 326184AA2FED1116006F8BFB /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 32A9ACE62DBA7B01000B7EDE /* Project object */; proxyType = 1; - remoteGlobalIDString = 32A9ACED2DBA7B01000B7EDE; - remoteInfo = Bemol; - }; - 32A9AD122DBA7B03000B7EDE /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 32A9ACE62DBA7B01000B7EDE /* Project object */; - proxyType = 1; - remoteGlobalIDString = 32A9ACED2DBA7B01000B7EDE; - remoteInfo = Bemol; + remoteGlobalIDString = 32617A972FEAFC5B006F8BFB; + remoteInfo = Bemol.macOS; }; /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 32147CC42DE25C4E003C3967 /* alt-store.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "alt-store.json"; sourceTree = ""; }; + 321422DC2FE9BA0D00BEDF1F /* Bemol.Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Bemol.Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 322CBD412DD5A8CB0092E1AC /* PRIVACY.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = PRIVACY.md; sourceTree = ""; }; 324CBF902DC372B7002B3923 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; 324CBF912DC37358002B3923 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; + 32617A9F2FEAFC5B006F8BFB /* Bemol.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Bemol.app; sourceTree = BUILT_PRODUCTS_DIR; }; 32A9ACEE2DBA7B01000B7EDE /* Bemol.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Bemol.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 32A9AD072DBA7B03000B7EDE /* BemolTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BemolTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 32A9AD112DBA7B03000B7EDE /* BemolUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BemolUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 32A9AE492DBF9692000B7EDE /* Bemol.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = Bemol.xctestplan; sourceTree = ""; }; 32A9AE642DC11956000B7EDE /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - 32A9AD192DBA7B03000B7EDE /* Exceptions for "Bemol" folder in "Bemol" target */ = { + 3215DAEE2FEA639900B94FCD /* Exceptions for "Scripts" folder in "Bemol.iOS" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - Info.plist, + make_ios_app.sh, + make_macos_app.sh, + next_build_version.sh, + next_marketing_version.sh, + test.sh, ); - target = 32A9ACED2DBA7B01000B7EDE /* Bemol */; + target = 32A9ACED2DBA7B01000B7EDE /* Bemol.iOS */; + }; + 32617AA12FEAFC5C006F8BFB /* Exceptions for "macOS" folder in "Bemol.macOS" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + "Bemol-macOS-Info.plist", + Configurations/Debug.xcconfig, + Configurations/Release.xcconfig, + Configurations/Shared.xcconfig, + Configurations/Signing.xcconfig, + Configurations/Versioning.xcconfig, + ExportOptions.plist, + ); + target = 32617A972FEAFC5B006F8BFB /* Bemol.macOS */; + }; + 32617AA72FEAFCF5006F8BFB /* Exceptions for "Shared" folder in "Bemol.macOS" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + App.swift, + AppAction.swift, + AppEffect.swift, + AppEffectHandler.swift, + AppEnvironment.swift, + AppLoop.swift, + AppState.swift, + Models/Music/Cadence.swift, + Models/Music/Note.swift, + Models/Music/NoteName.swift, + Models/Music/Octave.swift, + Models/Music/Resolution.swift, + Models/Practice/Level.swift, + Models/Practice/Question.swift, + Models/Practice/Session.swift, + Models/Tip/Tip.swift, + Resources/Assets.xcassets, + Resources/Localizable.xcstrings, + Resources/sound_font.sf2, + Screens/AccuracyScreen.swift, + Screens/ErrorScreen.swift, + Screens/LevelEditorScreen.swift, + Screens/MainScreen.swift, + Screens/MainScreenState.swift, + Services/Implementations/CyclicPracticeManager.swift, + Services/Implementations/DiatonicLevelGenerator.swift, + Services/Implementations/DiatonicNoteResolutionGenerator.swift, + Services/Implementations/FileSessionStorage.swift, + Services/Implementations/MIDINotePlayer.swift, + Services/Implementations/OnboardingTipProvider.swift, + "Services/Implementations/UserDefaults+Preferences.swift", + Services/LevelGenerator.swift, + Services/NotePlayer.swift, + Services/NoteResolutionGenerator.swift, + Services/PracticeManager.swift, + Services/Preferences.swift, + Services/SessionStorage.swift, + Services/TipProvider.swift, + Tokens/Colors.swift, + Tokens/CornerRadii.swift, + Tokens/Fonts.swift, + Tokens/Spacings.swift, + Views/Core/Action.swift, + Views/Core/BezierPath.swift, + Views/Core/Button.swift, + Views/Core/CGAffineTransform.swift, + Views/Core/Control.swift, + Views/Core/Event.swift, + Views/Core/Label.swift, + Views/Core/LayoutConstraint.swift, + Views/Core/ScrollView.swift, + Views/Core/Touch.swift, + Views/Core/View.swift, + Views/Keyboard/BlackKey.swift, + Views/Keyboard/KeyboardView.swift, + Views/Keyboard/OctaveView.swift, + Views/Keyboard/WhiteKey.swift, + Views/NavBar/AccuracyRing.swift, + Views/NavBar/NavBar.swift, + Views/NavBar/NavBarState.swift, + Views/NavBar/TitleView.swift, + Views/Tip/TipPresenter.swift, + Views/Tip/TipView.swift, + Views/TitleBar/TitleBar.swift, + ); + target = 32617A972FEAFC5B006F8BFB /* Bemol.macOS */; + }; + 32F3398A2FE733220007F4E2 /* Exceptions for "Shared" folder in "Bemol.iOS" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + App.swift, + AppAction.swift, + AppEffect.swift, + AppEffectHandler.swift, + AppEnvironment.swift, + AppLoop.swift, + AppState.swift, + Models/Music/Cadence.swift, + Models/Music/Note.swift, + Models/Music/NoteName.swift, + Models/Music/Octave.swift, + Models/Music/Resolution.swift, + Models/Practice/Level.swift, + Models/Practice/Question.swift, + Models/Practice/Session.swift, + Models/Tip/Tip.swift, + Resources/Assets.xcassets, + Resources/Localizable.xcstrings, + Resources/sound_font.sf2, + Screens/AccuracyScreen.swift, + Screens/ErrorScreen.swift, + Screens/LevelEditorScreen.swift, + Screens/MainScreen.swift, + Screens/MainScreenState.swift, + Services/Implementations/CyclicPracticeManager.swift, + Services/Implementations/DiatonicLevelGenerator.swift, + Services/Implementations/DiatonicNoteResolutionGenerator.swift, + Services/Implementations/FileSessionStorage.swift, + Services/Implementations/MIDINotePlayer.swift, + Services/Implementations/OnboardingTipProvider.swift, + "Services/Implementations/UserDefaults+Preferences.swift", + Services/LevelGenerator.swift, + Services/NotePlayer.swift, + Services/NoteResolutionGenerator.swift, + Services/PracticeManager.swift, + Services/Preferences.swift, + Services/SessionStorage.swift, + Services/TipProvider.swift, + Tokens/Colors.swift, + Tokens/CornerRadii.swift, + Tokens/Fonts.swift, + Tokens/Spacings.swift, + Views/Core/Action.swift, + Views/Core/BezierPath.swift, + Views/Core/Button.swift, + Views/Core/CGAffineTransform.swift, + Views/Core/Control.swift, + Views/Core/Event.swift, + Views/Core/Label.swift, + Views/Core/LayoutConstraint.swift, + Views/Core/ScrollView.swift, + Views/Core/Touch.swift, + Views/Core/View.swift, + Views/Keyboard/BlackKey.swift, + Views/Keyboard/KeyboardView.swift, + Views/Keyboard/OctaveView.swift, + Views/Keyboard/WhiteKey.swift, + Views/NavBar/AccuracyRing.swift, + Views/NavBar/NavBar.swift, + Views/NavBar/NavBarState.swift, + Views/NavBar/TitleView.swift, + Views/Tip/TipPresenter.swift, + Views/Tip/TipView.swift, + Views/TitleBar/TitleBar.swift, + ); + target = 32A9ACED2DBA7B01000B7EDE /* Bemol.iOS */; + }; + 32F339A92FE734E70007F4E2 /* Exceptions for "iOS" folder in "Bemol.iOS" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + "Bemol-iOS-Info.plist", + Configurations/Debug.xcconfig, + Configurations/Release.xcconfig, + Configurations/Shared.xcconfig, + Configurations/Signing.xcconfig, + Configurations/Versioning.xcconfig, + TestFlightExportOptions.plist, + ); + target = 32A9ACED2DBA7B01000B7EDE /* Bemol.iOS */; }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - 324CBFA22DC3E2D9002B3923 /* Configurations */ = { + 321422DD2FE9BA0D00BEDF1F /* Tests */ = { isa = PBXFileSystemSynchronizedRootGroup; - path = Configurations; + path = Tests; sourceTree = ""; }; - 32A9ACF02DBA7B01000B7EDE /* Bemol */ = { + 3215DAED2FEA639800B94FCD /* Scripts */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( - 32A9AD192DBA7B03000B7EDE /* Exceptions for "Bemol" folder in "Bemol" target */, + 3215DAEE2FEA639900B94FCD /* Exceptions for "Scripts" folder in "Bemol.iOS" target */, ); - path = Bemol; - sourceTree = ""; - }; - 32A9AD0A2DBA7B03000B7EDE /* BemolTests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = BemolTests; + path = Scripts; sourceTree = ""; }; - 32A9AD142DBA7B03000B7EDE /* BemolUITests */ = { + 32A9ACF02DBA7B01000B7EDE /* iOS */ = { isa = PBXFileSystemSynchronizedRootGroup; - path = BemolUITests; + exceptions = ( + 32F339A92FE734E70007F4E2 /* Exceptions for "iOS" folder in "Bemol.iOS" target */, + ); + path = iOS; sourceTree = ""; }; - 32A9AE852DC269B9000B7EDE /* Scripts */ = { + 32F339122FE70F700007F4E2 /* macOS */ = { isa = PBXFileSystemSynchronizedRootGroup; - path = Scripts; + exceptions = ( + 32617AA12FEAFC5C006F8BFB /* Exceptions for "macOS" folder in "Bemol.macOS" target */, + ); + path = macOS; sourceTree = ""; }; - 32A9AE892DC26A61000B7EDE /* Resources */ = { + 32F339892FE733180007F4E2 /* Shared */ = { isa = PBXFileSystemSynchronizedRootGroup; - path = Resources; + exceptions = ( + 32617AA72FEAFCF5006F8BFB /* Exceptions for "Shared" folder in "Bemol.macOS" target */, + 32F3398A2FE733220007F4E2 /* Exceptions for "Shared" folder in "Bemol.iOS" target */, + ); + path = Shared; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ - 32A9ACEB2DBA7B01000B7EDE /* Frameworks */ = { + 321422D92FE9BA0D00BEDF1F /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; - 32A9AD042DBA7B03000B7EDE /* Frameworks */ = { + 32617A992FEAFC5B006F8BFB /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; - 32A9AD0E2DBA7B03000B7EDE /* Frameworks */ = { + 32A9ACEB2DBA7B01000B7EDE /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( @@ -109,13 +281,11 @@ 32A9ACE52DBA7B01000B7EDE = { isa = PBXGroup; children = ( - 324CBFA22DC3E2D9002B3923 /* Configurations */, - 32A9ACF02DBA7B01000B7EDE /* Bemol */, - 32A9AD0A2DBA7B03000B7EDE /* BemolTests */, - 32A9AD142DBA7B03000B7EDE /* BemolUITests */, - 32A9AE892DC26A61000B7EDE /* Resources */, - 32A9AE852DC269B9000B7EDE /* Scripts */, - 32147CC42DE25C4E003C3967 /* alt-store.json */, + 32F339892FE733180007F4E2 /* Shared */, + 32A9ACF02DBA7B01000B7EDE /* iOS */, + 32F339122FE70F700007F4E2 /* macOS */, + 321422DD2FE9BA0D00BEDF1F /* Tests */, + 3215DAED2FEA639800B94FCD /* Scripts */, 32A9AE492DBF9692000B7EDE /* Bemol.xctestplan */, 322CBD412DD5A8CB0092E1AC /* PRIVACY.md */, 32A9AE642DC11956000B7EDE /* PrivacyInfo.xcprivacy */, @@ -129,8 +299,8 @@ isa = PBXGroup; children = ( 32A9ACEE2DBA7B01000B7EDE /* Bemol.app */, - 32A9AD072DBA7B03000B7EDE /* BemolTests.xctest */, - 32A9AD112DBA7B03000B7EDE /* BemolUITests.xctest */, + 321422DC2FE9BA0D00BEDF1F /* Bemol.Tests.xctest */, + 32617A9F2FEAFC5B006F8BFB /* Bemol.app */, ); name = Products; sourceTree = ""; @@ -138,74 +308,73 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - 32A9ACED2DBA7B01000B7EDE /* Bemol */ = { + 321422DB2FE9BA0D00BEDF1F /* Bemol.Tests */ = { isa = PBXNativeTarget; - buildConfigurationList = 32A9AD1A2DBA7B03000B7EDE /* Build configuration list for PBXNativeTarget "Bemol" */; + buildConfigurationList = 321422E22FE9BA0D00BEDF1F /* Build configuration list for PBXNativeTarget "Bemol.Tests" */; buildPhases = ( - 32A9ACEA2DBA7B01000B7EDE /* Sources */, - 32A9ACEB2DBA7B01000B7EDE /* Frameworks */, - 32A9ACEC2DBA7B01000B7EDE /* Resources */, + 321422D82FE9BA0D00BEDF1F /* Sources */, + 321422D92FE9BA0D00BEDF1F /* Frameworks */, + 321422DA2FE9BA0D00BEDF1F /* Resources */, ); buildRules = ( ); dependencies = ( + 326184AB2FED1116006F8BFB /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( - 324CBFA22DC3E2D9002B3923 /* Configurations */, - 32A9ACF02DBA7B01000B7EDE /* Bemol */, + 321422DD2FE9BA0D00BEDF1F /* Tests */, ); - name = Bemol; + name = Bemol.Tests; packageProductDependencies = ( ); - productName = Bemol; - productReference = 32A9ACEE2DBA7B01000B7EDE /* Bemol.app */; - productType = "com.apple.product-type.application"; + productName = Tests; + productReference = 321422DC2FE9BA0D00BEDF1F /* Bemol.Tests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; }; - 32A9AD062DBA7B03000B7EDE /* BemolTests */ = { + 32617A972FEAFC5B006F8BFB /* Bemol.macOS */ = { isa = PBXNativeTarget; - buildConfigurationList = 32A9AD1F2DBA7B03000B7EDE /* Build configuration list for PBXNativeTarget "BemolTests" */; + buildConfigurationList = 32617A9C2FEAFC5B006F8BFB /* Build configuration list for PBXNativeTarget "Bemol.macOS" */; buildPhases = ( - 32A9AD032DBA7B03000B7EDE /* Sources */, - 32A9AD042DBA7B03000B7EDE /* Frameworks */, - 32A9AD052DBA7B03000B7EDE /* Resources */, + 32617A982FEAFC5B006F8BFB /* Sources */, + 32617A992FEAFC5B006F8BFB /* Frameworks */, + 32617A9A2FEAFC5B006F8BFB /* Resources */, ); buildRules = ( ); dependencies = ( - 32A9AD092DBA7B03000B7EDE /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( - 32A9AD0A2DBA7B03000B7EDE /* BemolTests */, + 32F339122FE70F700007F4E2 /* macOS */, ); - name = BemolTests; + name = Bemol.macOS; packageProductDependencies = ( ); - productName = BemolTests; - productReference = 32A9AD072DBA7B03000B7EDE /* BemolTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; + productName = "Bemol-macOS"; + productReference = 32617A9F2FEAFC5B006F8BFB /* Bemol.app */; + productType = "com.apple.product-type.application"; }; - 32A9AD102DBA7B03000B7EDE /* BemolUITests */ = { + 32A9ACED2DBA7B01000B7EDE /* Bemol.iOS */ = { isa = PBXNativeTarget; - buildConfigurationList = 32A9AD222DBA7B03000B7EDE /* Build configuration list for PBXNativeTarget "BemolUITests" */; + buildConfigurationList = 32A9AD1A2DBA7B03000B7EDE /* Build configuration list for PBXNativeTarget "Bemol.iOS" */; buildPhases = ( - 32A9AD0D2DBA7B03000B7EDE /* Sources */, - 32A9AD0E2DBA7B03000B7EDE /* Frameworks */, - 32A9AD0F2DBA7B03000B7EDE /* Resources */, + 32A9ACEA2DBA7B01000B7EDE /* Sources */, + 32A9ACEB2DBA7B01000B7EDE /* Frameworks */, + 32A9ACEC2DBA7B01000B7EDE /* Resources */, ); buildRules = ( ); dependencies = ( - 32A9AD132DBA7B03000B7EDE /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( - 32A9AD142DBA7B03000B7EDE /* BemolUITests */, + 3215DAED2FEA639800B94FCD /* Scripts */, + 32A9ACF02DBA7B01000B7EDE /* iOS */, ); - name = BemolUITests; + name = Bemol.iOS; packageProductDependencies = ( ); - productName = BemolUITests; - productReference = 32A9AD112DBA7B03000B7EDE /* BemolUITests.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; + productName = Bemol; + productReference = 32A9ACEE2DBA7B01000B7EDE /* Bemol.app */; + productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -214,19 +383,15 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1620; - LastUpgradeCheck = 1620; + LastSwiftUpdateCheck = 2650; + LastUpgradeCheck = 2650; TargetAttributes = { - 32A9ACED2DBA7B01000B7EDE = { - CreatedOnToolsVersion = 16.2; + 321422DB2FE9BA0D00BEDF1F = { + CreatedOnToolsVersion = 26.5; + TestTargetID = 32F339102FE70F700007F4E2; }; - 32A9AD062DBA7B03000B7EDE = { - CreatedOnToolsVersion = 16.2; - TestTargetID = 32A9ACED2DBA7B01000B7EDE; - }; - 32A9AD102DBA7B03000B7EDE = { + 32A9ACED2DBA7B01000B7EDE = { CreatedOnToolsVersion = 16.2; - TestTargetID = 32A9ACED2DBA7B01000B7EDE; }; }; }; @@ -246,53 +411,55 @@ projectDirPath = ""; projectRoot = ""; targets = ( - 32A9ACED2DBA7B01000B7EDE /* Bemol */, - 32A9AD062DBA7B03000B7EDE /* BemolTests */, - 32A9AD102DBA7B03000B7EDE /* BemolUITests */, + 32617A972FEAFC5B006F8BFB /* Bemol.macOS */, + 32A9ACED2DBA7B01000B7EDE /* Bemol.iOS */, + 321422DB2FE9BA0D00BEDF1F /* Bemol.Tests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - 32A9ACEC2DBA7B01000B7EDE /* Resources */ = { + 321422DA2FE9BA0D00BEDF1F /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; - 32A9AD052DBA7B03000B7EDE /* Resources */ = { + 32617A9A2FEAFC5B006F8BFB /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 32617A9B2FEAFC5B006F8BFB /* PrivacyInfo.xcprivacy in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; - 32A9AD0F2DBA7B03000B7EDE /* Resources */ = { + 32A9ACEC2DBA7B01000B7EDE /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 3215DAE62FE9E1EA00B94FCD /* PrivacyInfo.xcprivacy in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - 32A9ACEA2DBA7B01000B7EDE /* Sources */ = { + 321422D82FE9BA0D00BEDF1F /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; - 32A9AD032DBA7B03000B7EDE /* Sources */ = { + 32617A982FEAFC5B006F8BFB /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; - 32A9AD0D2DBA7B03000B7EDE /* Sources */ = { + 32A9ACEA2DBA7B01000B7EDE /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( @@ -302,46 +469,192 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - 32A9AD092DBA7B03000B7EDE /* PBXTargetDependency */ = { + 326184AB2FED1116006F8BFB /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = 32A9ACED2DBA7B01000B7EDE /* Bemol */; - targetProxy = 32A9AD082DBA7B03000B7EDE /* PBXContainerItemProxy */; - }; - 32A9AD132DBA7B03000B7EDE /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 32A9ACED2DBA7B01000B7EDE /* Bemol */; - targetProxy = 32A9AD122DBA7B03000B7EDE /* PBXContainerItemProxy */; + target = 32617A972FEAFC5B006F8BFB /* Bemol.macOS */; + targetProxy = 326184AA2FED1116006F8BFB /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + 321422E32FE9BA0D00BEDF1F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 26.4; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.tchirou.apps.bemol.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Bemol.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Bemol"; + }; + name = Debug; + }; + 321422E42FE9BA0D00BEDF1F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 26.4; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.tchirou.apps.bemol.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Bemol.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Bemol"; + }; + name = Release; + }; + 32617A9D2FEAFC5B006F8BFB /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = 32F339122FE70F700007F4E2 /* macOS */; + baseConfigurationReferenceRelativePath = Configurations/Debug.xcconfig; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; + ENABLE_USER_SELECTED_FILES = readonly; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "macOS/Bemol-macOS-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = Bemol; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; + INFOPLIST_KEY_NSHumanReadableCopyright = "2026 Faiçal Tchirou"; + INFOPLIST_KEY_NSPrincipalClass = NSApplication; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 26.4; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.tchirou.apps.bemol.macos.local; + PRODUCT_NAME = "$(PRODUCT_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + REGISTER_APP_GROUPS = YES; + SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = NO; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + 32617A9E2FEAFC5B006F8BFB /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = 32F339122FE70F700007F4E2 /* macOS */; + baseConfigurationReferenceRelativePath = Configurations/Release.xcconfig; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; + ENABLE_USER_SELECTED_FILES = readonly; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "macOS/Bemol-macOS-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = Bemol; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; + INFOPLIST_KEY_NSHumanReadableCopyright = "2026 Faiçal Tchirou"; + INFOPLIST_KEY_NSPrincipalClass = NSApplication; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 26.4; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.tchirou.apps.bemol.macos.local; + PRODUCT_NAME = "$(PRODUCT_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + REGISTER_APP_GROUPS = YES; + SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = NO; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; 32A9AD1B2DBA7B03000B7EDE /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReferenceAnchor = 324CBFA22DC3E2D9002B3923 /* Configurations */; - baseConfigurationReferenceRelativePath = Debug.xcconfig; + baseConfigurationReferenceAnchor = 32A9ACF02DBA7B01000B7EDE /* iOS */; + baseConfigurationReferenceRelativePath = Configurations/Debug.xcconfig; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; - DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = Bemol/Info.plist; + INFOPLIST_FILE = "iOS/Bemol-iOS-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = Bemol; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = ""; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = "$(MARKETING_VERSION)"; - PRODUCT_BUNDLE_IDENTIFIER = com.tchirou.apps.bemol; - PRODUCT_NAME = "$(TARGET_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = com.tchirou.apps.bemol.ios; + PRODUCT_NAME = "$(PRODUCT_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -355,31 +668,32 @@ }; 32A9AD1C2DBA7B03000B7EDE /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReferenceAnchor = 324CBFA22DC3E2D9002B3923 /* Configurations */; - baseConfigurationReferenceRelativePath = Release.xcconfig; + baseConfigurationReferenceAnchor = 32A9ACF02DBA7B01000B7EDE /* iOS */; + baseConfigurationReferenceRelativePath = Configurations/Release.xcconfig; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; - DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = Bemol/Info.plist; + INFOPLIST_FILE = "iOS/Bemol-iOS-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = Bemol; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = ""; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = "$(MARKETING_VERSION)"; - PRODUCT_BUNDLE_IDENTIFIER = com.tchirou.apps.bemol; - PRODUCT_NAME = "$(TARGET_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = com.tchirou.apps.bemol.ios; + PRODUCT_NAME = "$(PRODUCT_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -393,8 +707,6 @@ }; 32A9AD1D2DBA7B03000B7EDE /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReferenceAnchor = 324CBFA22DC3E2D9002B3923 /* Configurations */; - baseConfigurationReferenceRelativePath = Debug.xcconfig; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; @@ -455,13 +767,12 @@ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 6.0; }; name = Debug; }; 32A9AD1E2DBA7B03000B7EDE /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReferenceAnchor = 324CBFA22DC3E2D9002B3923 /* Configurations */; - baseConfigurationReferenceRelativePath = Release.xcconfig; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; @@ -514,117 +825,46 @@ SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_EMIT_LOC_STRINGS = YES; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 32A9AD202DBA7B03000B7EDE /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReferenceAnchor = 324CBFA22DC3E2D9002B3923 /* Configurations */; - baseConfigurationReferenceRelativePath = Debug.xcconfig; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = tchirou.com.BemolTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Bemol.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Bemol"; - }; - name = Debug; - }; - 32A9AD212DBA7B03000B7EDE /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = tchirou.com.BemolTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Bemol.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Bemol"; - }; - name = Release; - }; - 32A9AD232DBA7B03000B7EDE /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReferenceAnchor = 32A9ACF02DBA7B01000B7EDE /* Bemol */; - baseConfigurationReferenceRelativePath = Configurations/Debug.xcconfig; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = tchirou.com.BemolUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = Bemol; - }; - name = Debug; - }; - 32A9AD242DBA7B03000B7EDE /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = tchirou.com.BemolUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = Bemol; + VALIDATE_PRODUCT = YES; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 32A9ACE92DBA7B01000B7EDE /* Build configuration list for PBXProject "Bemol" */ = { + 321422E22FE9BA0D00BEDF1F /* Build configuration list for PBXNativeTarget "Bemol.Tests" */ = { isa = XCConfigurationList; buildConfigurations = ( - 32A9AD1D2DBA7B03000B7EDE /* Debug */, - 32A9AD1E2DBA7B03000B7EDE /* Release */, + 321422E32FE9BA0D00BEDF1F /* Debug */, + 321422E42FE9BA0D00BEDF1F /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 32A9AD1A2DBA7B03000B7EDE /* Build configuration list for PBXNativeTarget "Bemol" */ = { + 32617A9C2FEAFC5B006F8BFB /* Build configuration list for PBXNativeTarget "Bemol.macOS" */ = { isa = XCConfigurationList; buildConfigurations = ( - 32A9AD1B2DBA7B03000B7EDE /* Debug */, - 32A9AD1C2DBA7B03000B7EDE /* Release */, + 32617A9D2FEAFC5B006F8BFB /* Debug */, + 32617A9E2FEAFC5B006F8BFB /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 32A9AD1F2DBA7B03000B7EDE /* Build configuration list for PBXNativeTarget "BemolTests" */ = { + 32A9ACE92DBA7B01000B7EDE /* Build configuration list for PBXProject "Bemol" */ = { isa = XCConfigurationList; buildConfigurations = ( - 32A9AD202DBA7B03000B7EDE /* Debug */, - 32A9AD212DBA7B03000B7EDE /* Release */, + 32A9AD1D2DBA7B03000B7EDE /* Debug */, + 32A9AD1E2DBA7B03000B7EDE /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 32A9AD222DBA7B03000B7EDE /* Build configuration list for PBXNativeTarget "BemolUITests" */ = { + 32A9AD1A2DBA7B03000B7EDE /* Build configuration list for PBXNativeTarget "Bemol.iOS" */ = { isa = XCConfigurationList; buildConfigurations = ( - 32A9AD232DBA7B03000B7EDE /* Debug */, - 32A9AD242DBA7B03000B7EDE /* Release */, + 32A9AD1B2DBA7B03000B7EDE /* Debug */, + 32A9AD1C2DBA7B03000B7EDE /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; diff --git a/Bemol.xcodeproj/xcshareddata/xcschemes/Bemol (sv).xcscheme b/Bemol.xcodeproj/xcshareddata/xcschemes/Bemol (sv).xcscheme deleted file mode 100644 index dce421e..0000000 --- a/Bemol.xcodeproj/xcshareddata/xcschemes/Bemol (sv).xcscheme +++ /dev/null @@ -1,80 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Bemol.xcodeproj/xcshareddata/xcschemes/Bemol.xcscheme b/Bemol.xcodeproj/xcshareddata/xcschemes/Bemol.iOS.xcscheme similarity index 71% rename from Bemol.xcodeproj/xcshareddata/xcschemes/Bemol.xcscheme rename to Bemol.xcodeproj/xcshareddata/xcschemes/Bemol.iOS.xcscheme index 22adc2b..32070ec 100644 --- a/Bemol.xcodeproj/xcshareddata/xcschemes/Bemol.xcscheme +++ b/Bemol.xcodeproj/xcshareddata/xcschemes/Bemol.iOS.xcscheme @@ -1,11 +1,29 @@ + + + + + + + + + + @@ -34,37 +52,12 @@ default = "YES"> - - - - - - - - - - + structuredConsoleMode = "1" + disablePerformanceAntipatternChecker = "YES"> @@ -97,7 +91,7 @@ BuildableIdentifier = "primary" BlueprintIdentifier = "32A9ACED2DBA7B01000B7EDE" BuildableName = "Bemol.app" - BlueprintName = "Bemol" + BlueprintName = "Bemol.iOS" ReferencedContainer = "container:Bemol.xcodeproj"> diff --git a/Bemol.xcodeproj/xcshareddata/xcschemes/Bemol (fr).xcscheme b/Bemol.xcodeproj/xcshareddata/xcschemes/Bemol.macOS.xcscheme similarity index 59% rename from Bemol.xcodeproj/xcshareddata/xcschemes/Bemol (fr).xcscheme rename to Bemol.xcodeproj/xcshareddata/xcschemes/Bemol.macOS.xcscheme index 347ff5d..3da30a2 100644 --- a/Bemol.xcodeproj/xcshareddata/xcschemes/Bemol (fr).xcscheme +++ b/Bemol.xcodeproj/xcshareddata/xcschemes/Bemol.macOS.xcscheme @@ -1,11 +1,29 @@ + + + + + + + + + + @@ -27,28 +45,32 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES" - shouldAutocreateTestPlan = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES"> + + + + + allowLocationSimulation = "YES" + queueDebuggingEnableBacktraceRecording = "Yes"> @@ -63,9 +85,9 @@ runnableDebuggingMode = "0"> diff --git a/Bemol.xctestplan b/Bemol.xctestplan index 9f6e8b1..8f1a514 100644 --- a/Bemol.xctestplan +++ b/Bemol.xctestplan @@ -11,26 +11,16 @@ "defaultOptions" : { "targetForVariableExpansion" : { "containerPath" : "container:Bemol.xcodeproj", - "identifier" : "32A9ACED2DBA7B01000B7EDE", - "name" : "Bemol" + "identifier" : "32F339102FE70F700007F4E2", + "name" : "Bemol.macOS" } }, "testTargets" : [ { - "parallelizable" : true, "target" : { "containerPath" : "container:Bemol.xcodeproj", - "identifier" : "32A9AD062DBA7B03000B7EDE", - "name" : "BemolTests" - } - }, - { - "enabled" : false, - "parallelizable" : true, - "target" : { - "containerPath" : "container:Bemol.xcodeproj", - "identifier" : "32A9AD102DBA7B03000B7EDE", - "name" : "BemolUITests" + "identifier" : "321422DB2FE9BA0D00BEDF1F", + "name" : "Bemol.Tests" } } ], diff --git a/Bemol/AppLoop.swift b/Bemol/AppLoop.swift deleted file mode 100644 index 0992087..0000000 --- a/Bemol/AppLoop.swift +++ /dev/null @@ -1,507 +0,0 @@ -/// -/// AppLoop.swift -/// Bemol -/// -/// Copyright 2025 Faiçal Tchirou -/// -/// Bemol is free software: you can redistribute it and/or modify it under the terms of -/// the GNU General Public License as published by the Free Software Foundation, either version 3 -/// of the License, or (at your option) any later version. -/// -/// Bemol is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; -/// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. -/// See the GNU General Public License for more details. -/// -/// You should have received a copy of the GNU General Public License along with Foobar. -/// If not, see . -/// - -import Foundation - -struct AppLoopDelegate { - let didUpdateState: (AppState) -> Void -} - -@MainActor -final class AppLoop { - private let environment: AppEnvironment - private(set) var state: AppState - - var delegate: AppLoopDelegate? - - init(environment: AppEnvironment, initialState: AppState) { - self.environment = environment - self.state = initialState - } - - func dispatch(_ action: AppAction) { - environment.logger.log(level: .default, "\(action)") - - let (newState, effect) = nextState(currentState: state, action: action) - - state = newState - delegate?.didUpdateState(newState) - - if let effect { - Task { - if let action = try? await effect.run() { - self.dispatch(action) - } - } - } - } - - func nextState(currentState: AppState, action: AppAction) -> (AppState, AppEffect?) { - var nextState = currentState - - switch action { - // MARK: - Lifecycle Actions - - case .didLoad: - nextState.isLoading = true - - return ( - nextState, - AppEffect { [weak self] in - guard let self else { throw AppError.unexpected } - - try await self.environment - .notePlayer - .prepareToPlay() - - try await self.environment - .practiceManager - .prepareToPractice() - - return try await self.environment - .practiceManager - .moveToNextLevel() - }.mapTo(AppAction.didLoadLevel) - ) - - // MARK: - Onboarding Actions - - case .didDismissTip: - nextState.currentTip = environment.tipProvider.nextTip() - nextState.isInteractionEnabled = nextState.currentTip == nil - - if nextState.currentTip == nil { - environment.preferences.setValue(true, for: .userHasSeenOnboarding) - } - - return (nextState, nil) - - // MARK: - NavBar Actions - - case .didPressHomeButton: - nextState.isLoading = true - - return ( - nextState, - AppEffect { [weak self] in - guard let self else { throw AppError.unexpected } - return try await self.environment.practiceManager.moveToFirstLevel() - }.mapTo(AppAction.didLoadLevel) - ) - - case .didPressRandomButton: - nextState.isLoading = true - - return ( - nextState, - AppEffect { [weak self] in - guard let self else { throw AppError.unexpected } - return try await self.environment.practiceManager.moveToRandomLevel() - }.mapTo(AppAction.didLoadLevel) - ) - - case .didPressPreviousLevelButton: - nextState.isLoading = true - - return ( - nextState, - AppEffect { [weak self] in - guard let self else { throw AppError.unexpected } - return try await self.environment.practiceManager.moveToPreviousLevel() - }.mapTo(AppAction.didLoadLevel) - ) - - case .didPressNextLevelButton: - nextState.isLoading = true - - return ( - nextState, - AppEffect { [weak self] in - guard let self else { throw AppError.unexpected } - return try await self.environment.practiceManager.moveToNextLevel() - }.mapTo(AppAction.didLoadLevel) - ) - - case .didPressConfigureLevelButton: - nextState.isLevelEditorVisible = true - - return (nextState, nil) - - case .didPressStartStopLevelButton: - nextState.isPracticing.toggle() - nextState.highlightedNote = nil - - if nextState.isPracticing { - return ( - nextState, - AppEffect { [weak self] in - guard let self else { throw AppError.unexpected } - return try await self.environment - .practiceManager - .startSession() - }.mapTo(AppAction.didStartSession) - ) - } - - return ( - nextState, - AppEffect { [weak self] in - guard let self else { throw AppError.unexpected } - - return try await self.environment - .practiceManager - .stopCurrentSession() - }.mapTo(AppAction.didLoadLevel) - ) - - case .didPressRepeatQuestionButton: - guard currentState.isPracticing else { return (nextState, nil) } - - nextState.isInteractionEnabled = false - - return ( - nextState, - AppEffect { [weak self] in - guard - let self, - let level = nextState.level, - let question = nextState.question - else { - throw AppError.unexpected - } - - let cadence: () = try await self.environment - .notePlayer - .playCadence(level.cadence) - - try await self.environment - .notePlayer - .playNote(question.answer) - - return cadence - }.mapTo(AppAction.didPlayCadence) - ) - - case .didPressAccuracyRing: - nextState.isAccuracyScreenVisible = true - - return (nextState, nil) - - // MARK: - Keyboard Actions - - case .didPressNote(let note): - guard currentState.isPracticing else { - nextState.highlightedNote = (note, .amber) - - return ( - nextState, - AppEffect { [weak self] in - guard let self else { throw AppError.unexpected } - - try await self.environment - .notePlayer - .playNote(note) - - return nil - } - ) - } - - guard let level = currentState.level, let question = currentState.question else { - return (nextState, nil) - } - - let isCorrect = level.spansMultipleOctaves - ? note.name == question.answer.name - : note == question.answer - - if isCorrect { - return stateForCorrectNotePressed( - currentState: currentState, - question: question, - note: note - ) - } else { - return stateForWrongNotePressed( - currentState: currentState, - question: question, - note: note - ) - } - - case .didReleaseNote: - return (nextState, nil) - - // MARK: - Modal Actions - - case .didDismissLevelEditor: - nextState.isLevelEditorVisible = false - - return (nextState, nil) - - case .didDismissAccuracyScreen: - nextState.isAccuracyScreenVisible = false - - return (nextState, nil) - - case .didSelectNotes(let notes): - return ( - nextState, - AppEffect { [weak self] in - guard let self, let level = currentState.level else { throw AppError.unexpected } - - let newLevel = if Set(notes) == Set(level.notes) { - level - } else if let baseLevel = currentState.baseLevel, Set(notes) == Set(baseLevel.notes) { - baseLevel - } else { - level.withNotes(notes) - } - - return try await self.environment - .practiceManager - .setCurrentLevel(newLevel) - }.mapTo(AppAction.didLoadLevel) - ) - - - // MARK: - Async Actions - - case .didLoadLevel(.success(let level)): - nextState.isLoading = false - nextState.hasError = false - nextState.level = level - nextState.accuracy = Float(level.summary.average) - nextState.accuracyPerNote = level.summary.averagePerNote - nextState.session = nil - nextState.question = nil - nextState.answer = nil - nextState.highlightedNote = nil - nextState.isInteractionEnabled = true - - if !level.isCustom { - nextState.baseLevel = level - } - - if !environment.preferences.value(for: .userHasSeenOnboarding) { - nextState.currentTip = environment.tipProvider.nextTip() - nextState.isInteractionEnabled = nextState.currentTip == nil - } - - return (nextState, nil) - - case .didStartSession(.success(let session)), .didLogRightAnswer(.success(let session)): - nextState.isLoading = false - nextState.hasError = false - nextState.session = session - nextState.correctIdentifications = session.summary.correct - nextState.questionsCount = session.summary.correct + session.summary.wrong - nextState.accuracy = Float(session.summary.average) - nextState.accuracyPerNote = session.summary.averagePerNote - nextState.highlightedNote = nil - nextState.isInteractionEnabled = true - - return ( - nextState, - AppEffect { [weak self] in - guard let self else { throw AppError.unexpected } - - return try await self.environment.practiceManager.moveToNextQuestion() - }.mapTo(AppAction.didLoadQuestion) - ) - - case .didLogWrongAnswer(.success(let session)): - nextState.isLoading = false - nextState.hasError = false - nextState.session = session - nextState.correctIdentifications = session.summary.correct - nextState.questionsCount = session.summary.correct + session.summary.wrong - nextState.accuracy = Float(session.summary.average) - nextState.accuracyPerNote = session.summary.averagePerNote - nextState.isInteractionEnabled = true - - return (nextState, nil) - - case .didLoadQuestion(.success(let question)): - nextState.isLoading = false - nextState.hasError = false - nextState.question = question - nextState.highlightedNote = nil - nextState.isInteractionEnabled = false - - return ( - nextState, - AppEffect { [weak self] in - guard let self, let level = currentState.level else { throw AppError.unexpected } - let cadence: () = try await self.environment - .notePlayer - .playCadence(level.cadence) - - try await self.environment - .notePlayer - .playNote(question.answer) - - return cadence - }.mapTo(AppAction.didPlayCadence) - ) - - case .didPlayCadence(.success): - nextState.isInteractionEnabled = true - - return (nextState, nil) - - case .didPlayNoteInResolution(.success): - if currentState.currentlyPlayingResolution.isEmpty { - return ( - nextState, - AppEffect { [weak self] in - guard let self, let question = currentState.question else { throw AppError.unexpected } - - return try await self.environment - .practiceManager - .logCorrectAnswer(question.answer, for: question) - }.mapTo(AppAction.didLogRightAnswer) - ) - } - - let note = currentState.currentlyPlayingResolution[0] - - nextState.highlightedNote = (note, .systemGreen) - nextState.currentlyPlayingResolution = Array(currentState.currentlyPlayingResolution[1...]) - - return ( - nextState, - AppEffect { [weak self] in - guard let self else { throw AppError.unexpected } - - return try await self.environment - .notePlayer - .playNote(note) - }.mapTo(AppAction.didPlayNoteInResolution) - ) - - // MARK: - Error States - - case .didLoadLevel(.failure(let error)): - nextState.error = error - nextState.isLoading = false - nextState.hasError = true - return (nextState, nil) - - case .didStartSession(.failure(let error)): - nextState.error = error - nextState.isLoading = false - nextState.hasError = true - return (nextState, nil) - - case .didLoadQuestion(.failure(let error)): - nextState.error = error - nextState.isLoading = false - nextState.hasError = true - return (nextState, nil) - - case .didLogRightAnswer(.failure(let error)): - nextState.error = error - nextState.isLoading = false - nextState.hasError = true - return (nextState, nil) - - case .didLogWrongAnswer(.failure(let error)): - nextState.error = error - nextState.isLoading = false - nextState.hasError = true - return (nextState, nil) - - case .didPlayCadence(.failure(let error)): - nextState.error = error - nextState.isLoading = false - nextState.hasError = true - return (nextState, nil) - - case .didPlayNoteInResolution(.failure(let error)): - nextState.error = error - nextState.hasError = true - return (nextState, nil) - } - } - - private func stateForCorrectNotePressed( - currentState: AppState, - question: Question, - note: Note - ) -> (AppState, AppEffect?) { - var nextState = currentState - nextState.answer = note - nextState.highlightedNote = (note, .systemGreen) - nextState.isInteractionEnabled = false - nextState.currentlyPlayingResolution = currentState.question?.resolution ?? [] - - if let note = nextState.currentlyPlayingResolution.first { - nextState.highlightedNote = (note, .systemGreen) - } - - return ( - nextState, - AppEffect {}.mapTo(AppAction.didPlayNoteInResolution) - ) - } - - private func stateForWrongNotePressed( - currentState: AppState, - question: Question, - note: Note - ) -> (AppState, AppEffect?) { - var nextState = currentState - nextState.answer = note - nextState.highlightedNote = (note, .systemRed) - nextState.isInteractionEnabled = false - - return ( - nextState, - AppEffect { [weak self] in - guard - let self, - let question = currentState.question - else { - throw AppError.unexpected - } - - try await self.environment - .notePlayer - .playNote(note) - - return try await self.environment - .practiceManager - .logWrongAnswer(note, for: question) - }.mapTo(AppAction.didLogWrongAnswer) - ) - } -} - -// MARK: - AppError - -enum AppError: Error, LocalizedError { - case unexpected - - var errorDescription: String? { - switch self { - case .unexpected: - "💀 Something truly unexpected occurred!" - } - } -} diff --git a/Bemol/Info.plist b/Bemol/Info.plist deleted file mode 100644 index b3c1ab6..0000000 --- a/Bemol/Info.plist +++ /dev/null @@ -1,63 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Bemol - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - $(MARKETING_VERSION) - CFBundleVersion - $(CURRENT_PROJECT_VERSION) - LSApplicationCategoryType - public.app-category.education - LSRequiresIPhoneOS - - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneConfigurationName - Default Configuration - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - - - - - UIApplicationSupportsIndirectInputEvents - - UILaunchStoryboardName - LaunchScreen - UIStatusBarStyle - - UISupportedInterfaceOrientations - - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - - - diff --git a/Bemol/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Bemol/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 16f0968..0000000 --- a/Bemol/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "images" : [ - { - "filename" : "bemol.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "filename" : "bemol 1.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "tinted" - } - ], - "filename" : "bemol 2.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Bemol/Resources/Assets.xcassets/AppIcon.appiconset/bemol 1.png b/Bemol/Resources/Assets.xcassets/AppIcon.appiconset/bemol 1.png deleted file mode 100644 index 0b2ffc9..0000000 Binary files a/Bemol/Resources/Assets.xcassets/AppIcon.appiconset/bemol 1.png and /dev/null differ diff --git a/Bemol/Resources/Assets.xcassets/AppIcon.appiconset/bemol 2.png b/Bemol/Resources/Assets.xcassets/AppIcon.appiconset/bemol 2.png deleted file mode 100644 index 0b2ffc9..0000000 Binary files a/Bemol/Resources/Assets.xcassets/AppIcon.appiconset/bemol 2.png and /dev/null differ diff --git a/Bemol/Resources/Assets.xcassets/AppIcon.appiconset/bemol.png b/Bemol/Resources/Assets.xcassets/AppIcon.appiconset/bemol.png deleted file mode 100644 index 0b2ffc9..0000000 Binary files a/Bemol/Resources/Assets.xcassets/AppIcon.appiconset/bemol.png and /dev/null differ diff --git a/Bemol/Tokens/Fonts.swift b/Bemol/Tokens/Fonts.swift deleted file mode 100644 index 22db15d..0000000 --- a/Bemol/Tokens/Fonts.swift +++ /dev/null @@ -1,46 +0,0 @@ -/// -/// Fonts.swift -/// Bemol -/// -/// Copyright 2025 Faiçal Tchirou -/// -/// Bemol is free software: you can redistribute it and/or modify it under the terms of -/// the GNU General Public License as published by the Free Software Foundation, either version 3 -/// of the License, or (at your option) any later version. -/// -/// Bemol is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; -/// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. -/// See the GNU General Public License for more details. -/// -/// You should have received a copy of the GNU General Public License along with Foobar. -/// If not, see . -/// - -import UIKit - -extension UIFont { - static let largeTitle: UIFont = makeFont(textStyle: .largeTitle, size: 32, weight: .heavy) - static let title1: UIFont = makeFont(textStyle: .title1, size: 28, weight: .bold) - static let title2: UIFont = makeFont(textStyle: .title2, size: 22, weight: .bold) - static let title3: UIFont = makeFont(textStyle: .title3, size: 20, weight: .bold) - static let headline: UIFont = makeFont(textStyle: .headline, size: 17, weight: .medium) - static let subheadline: UIFont = makeFont(textStyle: .subheadline, size: 16) - static let body: UIFont = makeFont(textStyle: .body, size: 15) - static let callout: UIFont = makeFont(textStyle: .callout, size: 14) - static let footnote: UIFont = makeFont(textStyle: .footnote, size: 13) - static let boldFootnote: UIFont = makeFont(textStyle: .footnote, size: 13, weight: .semibold) - static let caption1: UIFont = makeFont(textStyle: .caption1, size: 12) - static let caption2: UIFont = makeFont(textStyle: .caption2, size: 11) - static let boldCaption2: UIFont = makeFont(textStyle: .caption2, size: 11, weight: .bold) -} - -private func makeFont( - textStyle: UIFont.TextStyle, - size: CGFloat, - weight: UIFont.Weight = .regular -) -> UIFont { - let font = UIFont.systemFont(ofSize: size, weight: weight) - return UIFontMetrics(forTextStyle: textStyle).scaledFont(for: font) -} - - diff --git a/README.md b/README.md index c6ad15c..71e1247 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,20 @@ # 🎵 Bemol -![GitHub Workflow Status](https://github.com/ftchirou/Bemol/actions/workflows/run-tests.yml/badge.svg) ![GitHub Workflow Status](https://github.com/ftchirou/Bemol/actions/workflows/upload-to-testflight.yml/badge.svg) ![GitHub release (latest SemVer)](https://img.shields.io/github/v/tag/ftchirou/Bemol) +![GitHub Workflow Status](https://github.com/ftchirou/Bemol/actions/workflows/run-tests.yml/badge.svg) ![GitHub Workflow Status](https://github.com/ftchirou/Bemol/actions/workflows/publish_macos_app.yml/badge.svg) ![GitHub release (latest SemVer)](https://img.shields.io/github/v/tag/ftchirou/Bemol) **Bemol** is a **free** and **open-source** ear training app that helps music hobbyists and music students train and develop [relative pitch](https://en.wikipedia.org/wiki/Relative_pitch), the ability to recognize a played musical note in a given [tonal context](https://en.wikipedia.org/wiki/Tonic_(music)).
-![bemol-home](https://github.com/user-attachments/assets/6e89b255-3dab-4d7e-9b5a-9c9276450c68) +![bemol-macos](./Screenshots/bemol-macos.png)
## Contents - [Background](#background) +- [Download](#download) - [How it works](#how-it-works) -- [How to get it](#how-to-get-it) -- [For developers](#for-developers) - - [How to build and run](#how-to-build-and-run) - - [How to run the tests](#how-to-run-the-tests) - - [How to ship](#how-to-ship) +- [Building from sources](#building-from-sources) +- [License](#license) # Background @@ -24,65 +22,30 @@ As this process repeats, the user starts to internalize the character of each note in a given tonal context. This helps develop [relative pitch](https://en.wikipedia.org/wiki/Relative_pitch) and eventually they will have a much easier time recognizing any note as long as a tonal context is clearly established. -This ear training method was first described and implemented by [Alain Benbassat](https://www.miles.be) in his free [Functional Ear Trainer desktop app](https://www.miles.be/software/functional-ear-trainer-v2/). **Bemol** is simply a free and open-source implementation of the method for iOS. +This ear training method was first described and implemented by [Alain Benbassat](https://www.miles.be) in his free [Functional Ear Trainer desktop app](https://www.miles.be/software/functional-ear-trainer-v2/). **Bemol** is simply a native, free and open-source implementation of the method for macOS. -# How it works - -**Bemol** is organized in a series of progressive levels. The first level consists only of the **first 4 notes** in the key of **C major**. Once the user has at least 90% accuracy in this level, they can move to the next one, where they can practice the **next 4 notes**. - -Once the entire scale is mastered, the next level will introduce **chromatic notes**. After this, a new key is introduced. This can go on until the user has practiced in all 12 major and 12 minor keys. Or they can choose just to practice in a random key after they have mastered the keys of C major and C minor. -# How to get it +# Download -1. **Bemol** is available on the [AltStore](https://altstore.io). - - First, download and install the AltStore PAL app [here](https://altstore.io/#Downloads). - - Then, copy [this URL](https://storage.googleapis.com/bemol/alt-store.json) and add it as a source in the AltStore app. - - Finally, download **Bemol** from within the AltStore app. -2. Alternatively, you can install **Bemol** from TestFlight [here](https://testflight.apple.com/join/8vhsQVQQ). This option may be useful if you don't have access to the AltStore or if you prefer to use the cutting-edge version of the app. +You can download the latest version from the [releases page](https://github.com/ftchirou/Bemol/releases). The minimum version of macOS required to run Bemol is **26.4**. -
+# How it works -> [!IMPORTANT] -> The version of the app in TestFlight is the most recent development version. Which means that it may contain newer features and/or improvements not yet available in the official release on the AltStore. On the other hand, it is also less stable and may contain newer bugs and crashes. You can help and contribute to the development of **Bemol** by reporting these bugs through TestFlight. +**Bemol** is organized in a series of progressive levels. The first level consists only of the **first 4 notes** in the key of **C major**. Once the user has at least 90% accuracy in this level, they can move to the next one, where they can practice the **next 4 notes**. -# For developers +Once the entire scale is mastered, the next level will introduce **chromatic notes**. After this, a new key is introduced. This can go on until the user has practiced in all 12 major and 12 minor keys. Or they can choose just to practice in a random key after they have mastered the keys of C major and C minor. -## How to build and run +# Building from sources -1. Install [Xcode](https://developer.apple.com/xcode/). **Bemol** is built with [Swift 6](https://www.swift.org) and [Xcode 16.2](https://developer.apple.com/documentation/xcode-release-notes/xcode-16_2-release-notes). -2. Clone the repository and run `cd Bemol/`. -3. Run `touch ./Configurations/Signing.xcconfig` to create an empty signing config file. +1. Install [Xcode](https://developer.apple.com/xcode/). The minimum required version is [26.5](https://developer.apple.com/documentation/xcode-release-notes/xcode-26_5-release-notes). +2. In a terminal, clone the repository and run `cd Bemol/`. 4. Run `open Bemol.xcodeproj` to open it in Xcode. -5. Press `Run` in Xcode to launch the app in a simulator. -6. If running on a device, you have to provide the id of your development team in `./Configurations/Signing.xcconfig`. The contents of the file should look like this: - - ``` - // Signing.xcconfig +5. Select the `Bemol.macOS` scheme and press `Run` in Xcode to build and launch the app. - CODE_SIGN_STYLE = Automatic - DEVELOPMENT_TEAM = - ``` > [!TIP] -> To be able to hear piano sounds (and not sine waves), you'll need to download a [sound font](https://en.wikipedia.org/wiki/SoundFont) in the `sf2` format and save it under `Bemol/Resources/sound_font.sf2`. The TestFlight version of **Bemol** uses an excellent and open-source sound font from [MuseScore](https://musescore.org/en) that you can download [here](https://musescore.org/en/handbook/3/soundfonts-and-sfz-files#list) (look for `MuseScore_General`). - - -## How to run the tests - -- **Product** -> **Test** in Xcode. -- Or run `./Scripts/test.sh` from the command line. The tests results bundle will then be available at `Artifacts/test-results.xcresult`. - -## How to ship - -Run `./Scripts/upload_to_testflight.sh ` to archive and upload a new build to App Store Connect. `./Scripts/next_marketing_version.sh` and `./Scripts/next_build_version.sh` can be used to automatically generate the required arguments. - -The script expects the following environment variables to be set: - -- `APPLE_ID` -- `APP_STORE_CONNECT_API_KEY` -- `APP_STORE_CONNECT_API_ISSUER` +> To be able to hear piano sounds (and not sine waves), you'll need to download a [sound font](https://en.wikipedia.org/wiki/SoundFont) in the `sf2` format and save it under `Bemol/Shared/Resources/sound_font.sf2`. [MuseScore](https://musescore.org/en) provides an excellent and open-source sound font that you can download [here](https://musescore.org/en/handbook/3/soundfonts-and-sfz-files#list) (look for `MuseScore_General`). -These are self-explanatory and their values can be found in App Store Connect. The script also expects a private key file to be available at `private_keys/AuthKey_.p8`. # License diff --git a/Screenshots/bemol-github.png b/Screenshots/bemol-github.png new file mode 100644 index 0000000..7e7557a Binary files /dev/null and b/Screenshots/bemol-github.png differ diff --git a/Screenshots/bemol-ios.png b/Screenshots/bemol-ios.png new file mode 100644 index 0000000..220cfa7 Binary files /dev/null and b/Screenshots/bemol-ios.png differ diff --git a/Screenshots/bemol-macos.png b/Screenshots/bemol-macos.png new file mode 100644 index 0000000..a757dea Binary files /dev/null and b/Screenshots/bemol-macos.png differ diff --git a/Scripts/upload_to_testflight.sh b/Scripts/make_ios_app.sh similarity index 55% rename from Scripts/upload_to_testflight.sh rename to Scripts/make_ios_app.sh index 8af5ade..119d908 100755 --- a/Scripts/upload_to_testflight.sh +++ b/Scripts/make_ios_app.sh @@ -1,6 +1,6 @@ #!/bin/bash -# upload_to_testflight.sh +# make_ios_app.sh # Bemol # # Copyright 2025 Faiçal Tchirou @@ -17,18 +17,20 @@ # If not, see . -# Usage: ./upload_to_testflight.sh . +# Usage: ./make_ios_app.sh . # set -e -export_path="./Artifacts" +cd "$(dirname "$0")" || exit 1 +cd .. || exit 1 + +export_path="./Artifacts/iOS" -# Clean up on exit. trap "rm -rf $export_path" EXIT if [ $# -lt 2 ]; then echo "Missing one or more arguments!" - echo "Usage: ./upload_to_testflight.sh " + echo "Usage: ./upload_ios_to_testflight.sh " exit 1 fi @@ -48,65 +50,66 @@ if [[ -z "${APP_STORE_CONNECT_API_ISSUER}" ]]; then fi rm -rf $export_path -mkdir $export_path +mkdir -p $export_path marketing_version=$1 build_version=$2 archive_path="$export_path/Bemol.xcarchive" ipa_path="$export_path/Bemol.ipa" -export_options_plist_path="./Resources/TestFlightExportOptions.plist" -authentication_key_path=`realpath ./private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY}.p8` +export_options_plist_path="./iOS/TestFlightExportOptions.plist" +authentication_key_path=$(realpath ./iOS/private_keys/AuthKey_"${APP_STORE_CONNECT_API_KEY}".p8) -# Set the marketing version echo "Setting the marketing version to $marketing_version ..." sed -i -E "s/MARKETING_VERSION.*/MARKETING_VERSION = $marketing_version/g" \ - ./Configurations/Versioning.xcconfig + ./iOS/Configurations/Versioning.xcconfig -# Set the build version echo "Setting the build version to $build_version ..." sed -i -E "s/CURRENT_PROJECT_VERSION.*/CURRENT_PROJECT_VERSION = $build_version/g" \ - ./Configurations/Versioning.xcconfig + ./iOS/Configurations/Versioning.xcconfig + +if [ -e ./iOS/Configurations/Versioning.xcconfig-E ]; then + rm ./iOS/Configurations/Versioning.xcconfig-E +fi -# Archive Bemol echo "Archiving ..." xcodebuild -project Bemol.xcodeproj \ - -scheme Bemol \ + -scheme Bemol.iOS \ + -xcconfig ./iOS/Configurations/Release.xcconfig \ -allowProvisioningUpdates \ -configuration Release \ -archivePath $archive_path \ - -authenticationKeyPath $authentication_key_path \ - -authenticationKeyID ${APP_STORE_CONNECT_API_KEY} \ - -authenticationKeyIssuerID ${APP_STORE_CONNECT_API_ISSUER} \ + -authenticationKeyPath "$authentication_key_path" \ + -authenticationKeyID "$APP_STORE_CONNECT_API_KEY" \ + -authenticationKeyIssuerID "$APP_STORE_CONNECT_API_ISSUER" \ archive -# Export the archive echo "Exporting ..." xcodebuild -exportArchive \ -allowProvisioningUpdates \ -archivePath $archive_path \ -exportPath $export_path \ -exportOptionsPlist $export_options_plist_path \ - -authenticationKeyPath $authentication_key_path \ - -authenticationKeyID ${APP_STORE_CONNECT_API_KEY} \ - -authenticationKeyIssuerID ${APP_STORE_CONNECT_API_ISSUER} + -authenticationKeyPath "$authentication_key_path" \ + -authenticationKeyID "$APP_STORE_CONNECT_API_KEY" \ + -authenticationKeyIssuerID "$APP_STORE_CONNECT_API_ISSUER" -# Validate the ipa echo "Validating ..." xcrun altool --validate-app \ --file $ipa_path \ --type ios \ - --apiKey ${APP_STORE_CONNECT_API_KEY} \ - --apiIssuer ${APP_STORE_CONNECT_API_ISSUER} + --apiKey "$APP_STORE_CONNECT_API_KEY" \ + --apiIssuer "$APP_STORE_CONNECT_API_ISSUER" \ + --p8-file-path "$authentication_key_path" -# Upload to TestFlight -echo "Uploading ..." +echo "Uploading to TestFlight ..." xcrun altool --upload-package $ipa_path \ --type ios \ - --bundle-version $build_version \ - --bundle-short-version-string $marketing_version \ - --apple-id ${APPLE_ID} \ - --bundle-id com.tchirou.apps.bemol \ - --apiKey ${APP_STORE_CONNECT_API_KEY} \ - --apiIssuer ${APP_STORE_CONNECT_API_ISSUER} + --bundle-version "$build_version" \ + --bundle-short-version-string "$marketing_version" \ + --apple-id "$APPLE_ID" \ + --bundle-id com.tchirou.apps.bemol.ios \ + --apiKey "$APP_STORE_CONNECT_API_KEY" \ + --apiIssuer "$APP_STORE_CONNECT_API_ISSUER" \ + --p8-file-path "$authentication_key_path" echo "Done ✅" diff --git a/Scripts/make_macos_app.sh b/Scripts/make_macos_app.sh new file mode 100644 index 0000000..999ab5a --- /dev/null +++ b/Scripts/make_macos_app.sh @@ -0,0 +1,116 @@ +#!/bin/bash + +# make_macos_app.sh +# Bemol +# +# Copyright 2026 Faiçal Tchirou +# +# Bemol is free software: you can redistribute it and/or modify it under the terms of +# the GNU General Public License as published by the Free Software Foundation, either version 3 +# of the License, or (at your option) any later version. +# +# Bemol is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Foobar. +# If not, see . + +# References +# ============== +# +# Creating Developer ID certificates: https://developer.apple.com/help/account/certificates/create-developer-id-certificates/ +# Signing macOS apps for distribution: https://developer.apple.com/documentation/xcode/creating-distribution-signed-code-for-the-mac +# Notarizing macOS apps: https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution +# Notarization in custom build processes: https://developer.apple.com/documentation/security/customizing-the-notarization-workflow +# Resolving common notarization issues: https://developer.apple.com/documentation/security/resolving-common-notarization-issues + +set -e + +cd "$(dirname "$0")" || exit 1 +cd .. || exit 1 + +if [ $# -lt 2 ]; then + echo "Missing one or more arguments!" + echo "Usage: ./make_macos_app.sh " + exit 1 +fi + +if [[ -z "${APP_STORE_CONNECT_API_KEY}" ]]; then + echo "APP_STORE_CONNECT_API_KEY not set!" + exit 1 +fi + +if [[ -z "${APP_STORE_CONNECT_API_ISSUER}" ]]; then + echo "APP_STORE_CONNECT_API_ISSUER not set!" + exit 1 +fi + + +export_dir="./Artifacts/macOS" + +rm -rf $export_dir +mkdir -p $export_dir + +marketing_version=$1 +build_version=$2 +archive_path="$export_dir/Bemol.xcarchive" +export_options_plist="./macOS/ExportOptions.plist" +authentication_key_path=$(realpath ./macOS/private_keys/AuthKey_"$APP_STORE_CONNECT_API_KEY".p8) + +echo "Setting the marketing version to $marketing_version ..." +sed -i -E "s/MARKETING_VERSION.*/MARKETING_VERSION = $marketing_version/g" \ + ./macOS/Configurations/Versioning.xcconfig + +echo "Setting the build version to $build_version ..." +sed -i -E "s/CURRENT_PROJECT_VERSION.*/CURRENT_PROJECT_VERSION = $build_version/g" \ + ./macOS/Configurations/Versioning.xcconfig + +if [ -e ./macOS/Configurations/Versioning.xcconfig-E ]; then + rm ./macOS/Configurations/Versioning.xcconfig-E +fi + +echo "Archiving ..." +xcodebuild -project Bemol.xcodeproj \ + -scheme Bemol.macOS \ + -xcconfig ./macOS/Configurations/Release.xcconfig \ + -configuration Release \ + -archivePath $archive_path \ + -authenticationKeyPath "$authentication_key_path" \ + -authenticationKeyID "$APP_STORE_CONNECT_API_KEY" \ + -authenticationKeyIssuerID "$APP_STORE_CONNECT_API_ISSUER" \ + archive + +echo "Exporting ..." +xcodebuild -exportArchive \ + -archivePath $archive_path \ + -exportPath $export_dir \ + -exportOptionsPlist $export_options_plist \ + -authenticationKeyPath "$authentication_key_path" \ + -authenticationKeyID "$APP_STORE_CONNECT_API_KEY" \ + -authenticationKeyIssuerID "$APP_STORE_CONNECT_API_ISSUER" + +app_path="$export_dir"/Bemol.app +zip_path="$export_dir"/Bemol.zip + +/usr/bin/ditto -c -k --keepParent "$app_path" "$zip_path" + +echo "Notarizing ..." + +xcrun notarytool store-credentials "notarytool-credentials" \ + --key "$authentication_key_path" \ + --key-id "$APP_STORE_CONNECT_API_KEY" \ + --issuer "$APP_STORE_CONNECT_API_ISSUER" + +xcrun notarytool submit $zip_path \ + --keychain-profile "notarytool-credentials" \ + --verbose \ + --wait \ + --timeout 2h \ + --output-format plist > ./Artifacts/macOS/NotarizationResponse.plist + +xcrun stapler staple $app_path +rm "$zip_path" +/usr/bin/ditto -c -k --keepParent "$app_path" "$zip_path" + +echo "Done." diff --git a/Scripts/test.sh b/Scripts/test.sh index 481d30d..8fb019d 100755 --- a/Scripts/test.sh +++ b/Scripts/test.sh @@ -19,16 +19,16 @@ set -e -artifacts_path=./Artifacts +artifacts_path=./Artifacts/Tests result_bundle_path=$artifacts_path/test-results.xcresult rm -rf $result_bundle_path rm -rf $artifacts_path -mkdir $artifacts_path +mkdir -p $artifacts_path xcodebuild test -project Bemol.xcodeproj \ - -scheme Bemol \ + -scheme Bemol.macOS \ -testPlan Bemol \ - -destination 'platform=iOS Simulator,name=iPhone 16' \ + -destination 'platform=macOS' \ -enableCodeCoverage YES \ -resultBundlePath $result_bundle_path diff --git a/Bemol/App.swift b/Shared/App.swift similarity index 88% rename from Bemol/App.swift rename to Shared/App.swift index 8bf128f..673bcc1 100644 --- a/Bemol/App.swift +++ b/Shared/App.swift @@ -17,7 +17,14 @@ /// import Foundation + +#if os(macOS) +import AppKit +#endif + +#if os(iOS) import UIKit +#endif @MainActor final class App { @@ -69,47 +76,47 @@ final class App { // MARK: - Views - private lazy var mainView: UIView = { + private lazy var mainView: View = { let view = main.view - view.translatesAutoresizingMaskIntoConstraints = false + view.setUp() return view }() - private lazy var levelEditorView: UIView = { + private lazy var levelEditorView: View = { let view = levelEditor.view - view.translatesAutoresizingMaskIntoConstraints = false + view.setUp() view.isHidden = true return view }() - private lazy var accuracyView: UIView = { + private lazy var accuracyView: View = { let view = accuracy.view - view.translatesAutoresizingMaskIntoConstraints = false + view.setUp() view.isHidden = true return view }() - private lazy var errorView: UIView = { + private lazy var errorView: View = { let view = errorScreen.view - view.translatesAutoresizingMaskIntoConstraints = false + view.setUp() view.isHidden = true return view }() - lazy var view: UIView = { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false + lazy var view: View = { + let view = View() + view.setUp() view.addSubview(mainView) view.addSubview(levelEditorView) view.addSubview(accuracyView) view.addSubview(errorView) - NSLayoutConstraint.activate([ + LayoutConstraint.activate([ mainView.leadingAnchor.constraint(equalTo: view.leadingAnchor), mainView.topAnchor.constraint(equalTo: view.topAnchor), mainView.trailingAnchor.constraint(equalTo: view.trailingAnchor), @@ -138,7 +145,8 @@ final class App { private let environment: AppEnvironment private let loop: AppLoop - private var notePlayer: NotePlayer { + + private var notePlayer: any NotePlayer { environment.notePlayer } @@ -164,7 +172,7 @@ extension App { func didPressStartStopButton() { loop.dispatch(.didPressStartStopLevelButton) } - + func didPressRepeatButton() { loop.dispatch(.didPressRepeatQuestionButton) } @@ -172,23 +180,23 @@ extension App { func didPressNextButton() { loop.dispatch(.didPressNextLevelButton) } - + func didPressPreviousButton() { loop.dispatch(.didPressPreviousLevelButton) } - + func didPressProgressButton() { loop.dispatch(.didPressAccuracyRing) } - + func didPressConfigureButton() { loop.dispatch(.didPressConfigureLevelButton) } - + func didPressNote(_ note: Note) { loop.dispatch(.didPressNote(note)) } - + func didReleaseNote(_ note: Note) { loop.dispatch(.didReleaseNote(note)) } @@ -226,17 +234,20 @@ extension App { main.state = state.mainScreenState levelEditor.state = state.levelEditorScreenState accuracy.state = state.accuracyScreenState - mainView.isHidden = state.isLevelEditorVisible || state.isAccuracyScreenVisible || state.hasError + mainView.isHidden = + state.isLevelEditorVisible + || state.isAccuracyScreenVisible + || state.hasError errorView.isHidden = !state.hasError errorScreen.error = state.error if state.isAccuracyScreenVisible { - accuracy.state = nil // Force a state update. + accuracy.state = nil // Force a state update. accuracy.state = state.accuracyScreenState } if state.isLevelEditorVisible { - levelEditor.state = nil // Force a state update. + levelEditor.state = nil // Force a state update. levelEditor.state = state.levelEditorScreenState } diff --git a/Bemol/AppAction.swift b/Shared/AppAction.swift similarity index 66% rename from Bemol/AppAction.swift rename to Shared/AppAction.swift index 000a408..79ade97 100644 --- a/Bemol/AppAction.swift +++ b/Shared/AppAction.swift @@ -49,16 +49,19 @@ enum AppAction { case didDismissLevelEditor case didSelectNotes([Note]) - // MARK: - Async Actions + // MARK: - Practice Actions - case didLoadLevel(Result) - case didStartSession(Result) - case didLoadQuestion(Result) - case didLogRightAnswer(Result) - case didLogWrongAnswer(Result) + case didLoadLevel(Level) + case didStartSession(Session) + case didLoadQuestion(Question) + case didLogRightAnswer(Session) + case didLogWrongAnswer(Session) + case didPlayNoteInResolution + case didPlayCadence - case didPlayNoteInResolution(Result) - case didPlayCadence(Result) + // MARK: - Errors + + case errorOccurred(any Error) } // MARK: - CustomStringConvertible @@ -68,10 +71,8 @@ extension AppAction: CustomStringConvertible { switch self { case .didLoad: "✅ didLoad" - case .didDismissTip: "💡 didDismissTip" - case .didPressHomeButton: "👆 didPressHome" case .didPressRandomButton: @@ -98,43 +99,22 @@ extension AppAction: CustomStringConvertible { "👆 didDismissLevelEditor" case .didSelectNotes(let notes): "☑️ didSelectNotes \(notes.map { $0.name.letter })" - - - case .didLoadLevel(.success(let level)): + case .didLoadLevel(let level): "✅ didLoadLevel - \(level.id) - \(level.title)" - case .didLoadLevel(.failure(let error)): - "❌ didFailToLoadLevel - \(error.localizedDescription)" - - case .didStartSession(.success(let session)): + case .didStartSession(let session): "✅ didStartSession at \(session.timestamp)" - case .didStartSession(.failure(let error)): - "❌ didFailToStartSession - \(error.localizedDescription)" - - case .didLoadQuestion(.success(let question)): + case .didLoadQuestion(let question): "✅ didLoadQuestion - \(question.id) - \(question.answer.name.letter)" - case .didLoadQuestion(.failure(let error)): - "❌ didFailToLoadQuestion - \(error.localizedDescription)" - - case .didLogRightAnswer(.success(let session)): + case .didLogRightAnswer(let session): "👏 didLogRightAnswer in session started at \(session.timestamp)" - case .didLogRightAnswer(.failure(let error)): - "❌ didFailToLogRightAnswer - \(error.localizedDescription)" - - case .didLogWrongAnswer(.success(let session)): + case .didLogWrongAnswer(let session): "😅 didLogWrongAnswer in session started at \(session.timestamp)" - case .didLogWrongAnswer(.failure(let error)): - "❌ didFailToLogWrongAnswer - \(error.localizedDescription)" - - case .didPlayNoteInResolution(.success): + case .didPlayNoteInResolution: "🎵 didPlayNoteInResolution" - case .didPlayNoteInResolution(.failure(let error)): - "❌ didFailToPlayNoteInResolution - \(error.localizedDescription)" - - case .didPlayCadence(.success()): + case .didPlayCadence: "🎶 didPlayCadence" - case .didPlayCadence(.failure(let error)): - "❌ didFailToPlayCadence - \(error.localizedDescription)" + case .errorOccurred(let error): + "❌ errorOccurred - \(error.localizedDescription)" } } } - diff --git a/Bemol/AppEffect.swift b/Shared/AppEffect.swift similarity index 59% rename from Bemol/AppEffect.swift rename to Shared/AppEffect.swift index e88e89b..c5b2ba5 100644 --- a/Bemol/AppEffect.swift +++ b/Shared/AppEffect.swift @@ -18,27 +18,20 @@ import Foundation -struct AppEffect { - private let effect: () async throws -> T - - init(effect: @escaping () async throws -> T) { - self.effect = effect - } - - func mapTo( - _ transform: @escaping (Result) -> U - ) -> AppEffect { - AppEffect { - do { - let result = try await effect() - return transform(.success(result)) - } catch { - return transform(.failure(error)) - } - } - } - - func run() async throws -> T { - try await effect() - } +enum AppEffect: Equatable { + case prepareToPractice + case loadLevel(Level) + case loadFirstLevel + case loadRandomLevel + case loadPreviousLevel + case loadNextLevel + case startSession + case stopSession + case playNote(Note) + case playNoteInResolution(Note?) + case repeatQuestion(Level, Question) + case playCadence(Level, Question) + case loadNextQuestion + case logRightAnswer(Note, Question) + case logWrongAnswer(Note, Question) } diff --git a/Shared/AppEffectHandler.swift b/Shared/AppEffectHandler.swift new file mode 100644 index 0000000..83318f7 --- /dev/null +++ b/Shared/AppEffectHandler.swift @@ -0,0 +1,110 @@ +/// +/// AppLoop.swift +/// Bemol +/// +/// Copyright 2026 Faiçal Tchirou +/// +/// Bemol is free software: you can redistribute it and/or modify it under the terms of +/// the GNU General Public License as published by the Free Software Foundation, either version 3 +/// of the License, or (at your option) any later version. +/// +/// Bemol is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +/// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +/// See the GNU General Public License for more details. +/// +/// You should have received a copy of the GNU General Public License along with Foobar. +/// If not, see . +/// + +import Foundation +import os + +@MainActor +final class AppEffectHandler { + private let environment: AppEnvironment + + init(environment: AppEnvironment) { + self.environment = environment + } + + func handleEffect(_ effect: AppEffect) async -> AppAction? { + do { + switch effect { + case .prepareToPractice: + try await environment.notePlayer.prepareToPlay() + try await environment.practiceManager.prepareToPractice() + let level = try await environment.practiceManager.moveToNextLevel() + + return .didLoadLevel(level) + + case .loadLevel(let level): + let level = try await environment.practiceManager.setCurrentLevel(level) + return .didLoadLevel(level) + + case .loadFirstLevel: + let level = try await environment.practiceManager.moveToFirstLevel() + return .didLoadLevel(level) + + case .loadRandomLevel: + let level = try await environment.practiceManager.moveToRandomLevel() + return .didLoadLevel(level) + + case .loadPreviousLevel: + let level = try await environment.practiceManager.moveToPreviousLevel() + return .didLoadLevel(level) + + case .loadNextLevel: + let level = try await environment.practiceManager.moveToNextLevel() + return .didLoadLevel(level) + + case .startSession: + let session = try await environment.practiceManager.startSession() + return .didStartSession(session) + + case .stopSession: + let level = try await environment.practiceManager.stopCurrentSession() + return .didLoadLevel(level) + + case .repeatQuestion(let level, let question): + try await environment.notePlayer.playCadence(level.cadence) + try await environment.notePlayer.playNote(question.answer) + + return .didPlayCadence + + case .playNote(let note): + try await environment.notePlayer.playNote(note) + return nil + + case .playNoteInResolution(let note): + if let note { + try await environment.notePlayer.playNote(note) + } + + return .didPlayNoteInResolution + + case .loadNextQuestion: + let question = try await environment.practiceManager.moveToNextQuestion() + return .didLoadQuestion(question) + + case .logRightAnswer(let answer, let question): + let session = try await environment.practiceManager.logCorrectAnswer(answer, for: question) + return .didLogRightAnswer(session) + + case .logWrongAnswer(let answer, let question): + try await environment.notePlayer.playNote(answer) + let session = try await environment.practiceManager.logWrongAnswer(answer, for: question) + + return .didLogWrongAnswer(session) + + case .playCadence(let level, let question): + try await environment.notePlayer.playCadence(level.cadence) + try await environment.notePlayer.playNote(question.answer) + + return .didPlayCadence + } + } catch { + environment.logger.log(level: .error, "\(error)") + return .errorOccurred(error) + } + } +} diff --git a/Bemol/AppEnvironment.swift b/Shared/AppEnvironment.swift similarity index 84% rename from Bemol/AppEnvironment.swift rename to Shared/AppEnvironment.swift index 54b27fa..5d92d6d 100644 --- a/Bemol/AppEnvironment.swift +++ b/Shared/AppEnvironment.swift @@ -20,9 +20,9 @@ import Foundation import os struct AppEnvironment { - let notePlayer: NotePlayer - let practiceManager: PracticeManager - let tipProvider: TipProvider - let preferences: Preferences + let notePlayer: any NotePlayer + let practiceManager: any PracticeManager + let tipProvider: any TipProvider + let preferences: any Preferences let logger: Logger } diff --git a/Shared/AppLoop.swift b/Shared/AppLoop.swift new file mode 100644 index 0000000..82fc65f --- /dev/null +++ b/Shared/AppLoop.swift @@ -0,0 +1,337 @@ +/// +/// AppLoop.swift +/// Bemol +/// +/// Copyright 2025 Faiçal Tchirou +/// +/// Bemol is free software: you can redistribute it and/or modify it under the terms of +/// the GNU General Public License as published by the Free Software Foundation, either version 3 +/// of the License, or (at your option) any later version. +/// +/// Bemol is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +/// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +/// See the GNU General Public License for more details. +/// +/// You should have received a copy of the GNU General Public License along with Foobar. +/// If not, see . +/// + +import Foundation +import os + +#if os(macOS) +import AppKit +#endif + +#if os(iOS) +import UIKit +#endif + +struct AppLoopDelegate { + let didUpdateState: (AppState) -> Void +} + +@MainActor +final class AppLoop { + private let environment: AppEnvironment + private(set) var state: AppState + + var delegate: AppLoopDelegate? + + private lazy var effectHandler = AppEffectHandler(environment: environment) + + init(environment: AppEnvironment, initialState: AppState) { + self.environment = environment + self.state = initialState + } + + func dispatch(_ action: AppAction) { + environment.logger.log(level: .default, "\(action)") + + let (newState, effect) = nextState(currentState: state, action: action) + + state = newState + delegate?.didUpdateState(newState) + + if let effect { + Task { + if let action = await effectHandler.handleEffect(effect) { + self.dispatch(action) + } + } + } + } + + func nextState(currentState: AppState, action: AppAction) -> (AppState, AppEffect?) { + var nextState = currentState + + switch action { + // MARK: - Lifecycle Actions + + case .didLoad: + nextState.isLoading = true + + return (nextState, .prepareToPractice) + + // MARK: - Onboarding Actions + + case .didDismissTip: + nextState.currentTip = environment.tipProvider.nextTip() + nextState.isInteractionEnabled = nextState.currentTip == nil + + if nextState.currentTip == nil { + environment.preferences.setValue(true, for: .userHasSeenOnboarding) + } + + return (nextState, nil) + + // MARK: - NavBar Actions + + case .didPressHomeButton: + nextState.isLoading = true + + return (nextState, .loadFirstLevel) + + case .didPressRandomButton: + nextState.isLoading = true + + return (nextState, .loadRandomLevel) + + case .didPressPreviousLevelButton: + nextState.isLoading = true + + return (nextState, .loadPreviousLevel) + + case .didPressNextLevelButton: + nextState.isLoading = true + + return (nextState, .loadNextLevel) + + case .didPressConfigureLevelButton: + nextState.isLevelEditorVisible = true + + return (nextState, nil) + + case .didPressStartStopLevelButton: + nextState.isPracticing.toggle() + nextState.highlightedNote = nil + + if nextState.isPracticing { + return (nextState, .startSession) + } + + return (nextState, .stopSession) + + case .didPressRepeatQuestionButton: + guard + currentState.isPracticing, + let level = currentState.level, + let question = currentState.question + else { return (nextState, nil) } + + nextState.isInteractionEnabled = false + return (nextState, .repeatQuestion(level, question)) + + case .didPressAccuracyRing: + nextState.isAccuracyScreenVisible = true + + return (nextState, nil) + + // MARK: - Keyboard Actions + + case .didPressNote(let note): + guard currentState.isPracticing else { + nextState.highlightedNote = (note, .amber) + return (nextState, .playNote(note)) + } + + guard + let level = currentState.level, + let question = currentState.question + else { + return (nextState, nil) + } + + let isCorrect = + level.spansMultipleOctaves + ? note.name == question.answer.name + : note == question.answer + + if isCorrect { + return stateForCorrectNotePressed( + currentState: currentState, + question: question, + note: note + ) + } else { + return stateForWrongNotePressed( + currentState: currentState, + question: question, + note: note + ) + } + + case .didReleaseNote: + return (nextState, nil) + + // MARK: - Modal Actions + + case .didDismissLevelEditor: + nextState.isLevelEditorVisible = false + + return (nextState, nil) + + case .didDismissAccuracyScreen: + nextState.isAccuracyScreenVisible = false + + return (nextState, nil) + + case .didSelectNotes(let notes): + guard + let level = currentState.level, + Set(notes) != Set(level.notes) + else { return (nextState, nil) } + + let newLevel = + if let baseLevel = currentState.baseLevel, Set(notes) == Set(baseLevel.notes) { + baseLevel + } else { + level.withNotes(notes) + } + + return (nextState, .loadLevel(newLevel)) + + // MARK: - Async Actions + + case .didLoadLevel(let level): + nextState.isLoading = false + nextState.hasError = false + nextState.level = level + nextState.accuracy = Float(level.summary.average) + nextState.accuracyPerNote = level.summary.averagePerNote + nextState.session = nil + nextState.question = nil + nextState.answer = nil + nextState.highlightedNote = nil + nextState.isInteractionEnabled = true + + if !level.isCustom { + nextState.baseLevel = level + } + + if !environment.preferences.value(for: .userHasSeenOnboarding) { + nextState.currentTip = environment.tipProvider.nextTip() + nextState.isInteractionEnabled = nextState.currentTip == nil + } + + return (nextState, nil) + + case .didStartSession(let session), .didLogRightAnswer(let session): + nextState.isLoading = false + nextState.hasError = false + nextState.session = session + nextState.correctIdentifications = session.summary.correct + nextState.questionsCount = session.summary.correct + session.summary.wrong + nextState.accuracy = Float(session.summary.average) + nextState.accuracyPerNote = session.summary.averagePerNote + nextState.highlightedNote = nil + nextState.isInteractionEnabled = true + + return (nextState, .loadNextQuestion) + + case .didLogWrongAnswer(let session): + nextState.isLoading = false + nextState.hasError = false + nextState.session = session + nextState.correctIdentifications = session.summary.correct + nextState.questionsCount = session.summary.correct + session.summary.wrong + nextState.accuracy = Float(session.summary.average) + nextState.accuracyPerNote = session.summary.averagePerNote + nextState.isInteractionEnabled = true + + return (nextState, nil) + + case .didLoadQuestion(let question): + guard let level = currentState.level else { return (nextState, nil) } + + nextState.isLoading = false + nextState.hasError = false + nextState.question = question + nextState.highlightedNote = nil + nextState.isInteractionEnabled = false + + return (nextState, .playCadence(level, question)) + + case .didPlayCadence: + nextState.isInteractionEnabled = true + + return (nextState, nil) + + case .didPlayNoteInResolution: + guard let question = currentState.question else { return (nextState, nil) } + + if currentState.currentlyPlayingResolution.isEmpty { + return (nextState, .logRightAnswer(question.answer, question)) + } + + let note = currentState.currentlyPlayingResolution[0] + + nextState.highlightedNote = (note, .systemGreen) + nextState.currentlyPlayingResolution = Array(currentState.currentlyPlayingResolution[1...]) + + return (nextState, .playNoteInResolution(note)) + + // MARK: - Error States + + case .errorOccurred(let error): + nextState.error = error + nextState.isLoading = false + nextState.hasError = true + return (nextState, nil) + } + } + + private func stateForCorrectNotePressed( + currentState: AppState, + question: Question, + note: Note + ) -> (AppState, AppEffect?) { + var nextState = currentState + nextState.answer = note + nextState.highlightedNote = (note, .systemGreen) + nextState.isInteractionEnabled = false + nextState.currentlyPlayingResolution = currentState.question?.resolution ?? [] + + if let note = nextState.currentlyPlayingResolution.first { + nextState.highlightedNote = (note, .systemGreen) + } + + return (nextState, .playNoteInResolution(nil)) + } + + private func stateForWrongNotePressed( + currentState: AppState, + question: Question, + note: Note + ) -> (AppState, AppEffect?) { + var nextState = currentState + nextState.answer = note + nextState.highlightedNote = (note, .systemRed) + nextState.isInteractionEnabled = false + + return (nextState, .logWrongAnswer(note, question)) + } +} + +// MARK: - AppError + +enum AppError: Error, LocalizedError { + case unexpected + + var errorDescription: String? { + switch self { + case .unexpected: + "💀 Something truly unexpected occurred!" + } + } +} diff --git a/Bemol/AppState.swift b/Shared/AppState.swift similarity index 85% rename from Bemol/AppState.swift rename to Shared/AppState.swift index 8b63796..0bc87a6 100644 --- a/Bemol/AppState.swift +++ b/Shared/AppState.swift @@ -17,7 +17,14 @@ /// import Foundation + +#if os(macOS) +import AppKit +#endif + +#if os(iOS) import UIKit +#endif struct AppState { var isLoading: Bool = false @@ -33,11 +40,11 @@ struct AppState { var accuracyPerNote: [Note: Double] = [:] var isLevelEditorVisible: Bool = false var isAccuracyScreenVisible: Bool = false - var highlightedNote: (Note, UIColor)? = nil + var highlightedNote: (Note, Color)? = nil var hasError: Bool = false var isInteractionEnabled: Bool = false var currentlyPlayingResolution: [Note] = [] - var error: Error? = nil + var error: (any Error)? = nil var currentTip: Tip? = nil } @@ -45,25 +52,25 @@ extension AppState { var mainScreenState: MainScreenState { var title = level.flatMap { AttributedString($0.title) } if let octaveRange = title?.range(of: "8..") { - title?[octaveRange].foregroundColor = UIColor.systemGreen + title?[octaveRange].foregroundColor = Color.systemGreen } return MainScreenState( isLoading: isLoading, key: level?.key ?? .c, - isPreviousButtonEnabled: !isPracticing && isInteractionEnabled, - isNextButtonEnabled: !isPracticing && isInteractionEnabled, + isPreviousButtonEnabled: !isPracticing && isInteractionEnabled && !isModalVisible, + isNextButtonEnabled: !isPracticing && isInteractionEnabled && !isModalVisible, title: title, - isConfigureButtonEnabled: !isPracticing && isInteractionEnabled, - isStartStopButtonEnabled: isInteractionEnabled, + isConfigureButtonEnabled: !isPracticing && isInteractionEnabled && !isModalVisible, + isStartStopButtonEnabled: isInteractionEnabled && !isModalVisible, startStopButtonMode: isPracticing ? .stop : .start, isRepeatButtonHidden: !isPracticing, - isRepeatButtonEnabled: isInteractionEnabled, + isRepeatButtonEnabled: isInteractionEnabled && isPracticing && !isModalVisible, isScoreLabelHidden: !isPracticing, scoreText: scoreText, scoreAccessibilityText: scoreAccessibilityText, accuracy: accuracy, - isAccuracyRingEnabled: isInteractionEnabled, + isAccuracyRingEnabled: isInteractionEnabled && !isModalVisible, accuracyRingAccessibilityText: accuracyRingAccessibilityText, isKeyboardEnabled: isInteractionEnabled, activeNotes: level?.notes ?? [], @@ -89,6 +96,10 @@ extension AppState { ) } + private var isModalVisible: Bool { + isLevelEditorVisible || isAccuracyScreenVisible + } + private var scoreText: AttributedString { if questionsCount <= 0 { return AttributedString() @@ -107,7 +118,7 @@ extension AppState { } private var scoreAccessibilityText: String? { - if questionsCount <= 0 { + if questionsCount <= 0 || !isPracticing { return nil } diff --git a/Bemol/Models/Music/Cadence.swift b/Shared/Models/Music/Cadence.swift similarity index 100% rename from Bemol/Models/Music/Cadence.swift rename to Shared/Models/Music/Cadence.swift diff --git a/Bemol/Models/Music/Note.swift b/Shared/Models/Music/Note.swift similarity index 100% rename from Bemol/Models/Music/Note.swift rename to Shared/Models/Music/Note.swift diff --git a/Bemol/Models/Music/NoteName.swift b/Shared/Models/Music/NoteName.swift similarity index 99% rename from Bemol/Models/Music/NoteName.swift rename to Shared/Models/Music/NoteName.swift index 821ce6c..540d0e9 100644 --- a/Bemol/Models/Music/NoteName.swift +++ b/Shared/Models/Music/NoteName.swift @@ -41,7 +41,7 @@ enum NoteName: UInt8, Equatable { static let bFlat: NoteName = .aSharp static let all: [NoteName] = [ - .c, .cSharp, .d, .dSharp, .e, .f, .fSharp, .g, .gSharp, .a, .aSharp, .b + .c, .cSharp, .d, .dSharp, .e, .f, .fSharp, .g, .gSharp, .a, .aSharp, .b, ] // MARK: - Indexing @@ -187,7 +187,7 @@ extension NoteName { } } -fileprivate let solfegeNames = [ +private let solfegeNames = [ String(localized: "do").capitalized, "\(String(localized: "di").capitalized) / \(String(localized: "ra").capitalized)", String(localized: "re").capitalized, diff --git a/Bemol/Models/Music/Octave.swift b/Shared/Models/Music/Octave.swift similarity index 100% rename from Bemol/Models/Music/Octave.swift rename to Shared/Models/Music/Octave.swift diff --git a/Bemol/Models/Music/Resolution.swift b/Shared/Models/Music/Resolution.swift similarity index 100% rename from Bemol/Models/Music/Resolution.swift rename to Shared/Models/Music/Resolution.swift diff --git a/Bemol/Models/Practice/Level.swift b/Shared/Models/Practice/Level.swift similarity index 98% rename from Bemol/Models/Practice/Level.swift rename to Shared/Models/Practice/Level.swift index d7e762e..72c06b7 100644 --- a/Bemol/Models/Practice/Level.swift +++ b/Shared/Models/Practice/Level.swift @@ -18,13 +18,13 @@ import Foundation -enum NoteRange { +enum NoteRange: Equatable { case firstHalfOfOctave case secondHalfOfOctave case entireOctave } -struct Level { +struct Level: Equatable { let id: Int let key: NoteName let isMajor: Bool diff --git a/Bemol/Models/Practice/Question.swift b/Shared/Models/Practice/Question.swift similarity index 97% rename from Bemol/Models/Practice/Question.swift rename to Shared/Models/Practice/Question.swift index 1114440..0fb54bf 100644 --- a/Bemol/Models/Practice/Question.swift +++ b/Shared/Models/Practice/Question.swift @@ -18,7 +18,7 @@ import Foundation -struct Question { +struct Question: Equatable { let id: UUID let answer: Note let resolution: Resolution diff --git a/Bemol/Models/Practice/Session.swift b/Shared/Models/Practice/Session.swift similarity index 88% rename from Bemol/Models/Practice/Session.swift rename to Shared/Models/Practice/Session.swift index 92431c9..3752e0d 100644 --- a/Bemol/Models/Practice/Session.swift +++ b/Shared/Models/Practice/Session.swift @@ -18,9 +18,18 @@ import Foundation -struct Session { +struct Score: Equatable { + let correct: Int + let wrong: Int + + static var zero: Score { + Score(correct: 0, wrong: 0) + } +} + +struct Session: Equatable { let timestamp: TimeInterval - let score: [Note: (correct: Int, wrong: Int)] + let score: [Note: Score] } extension Session { diff --git a/Bemol/Models/Tip/Tip.swift b/Shared/Models/Tip/Tip.swift similarity index 100% rename from Bemol/Models/Tip/Tip.swift rename to Shared/Models/Tip/Tip.swift diff --git a/Bemol/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/Shared/Resources/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from Bemol/Resources/Assets.xcassets/AccentColor.colorset/Contents.json rename to Shared/Resources/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-1024x1024@1x 1.png b/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-1024x1024@1x 1.png new file mode 100644 index 0000000..16a1e0d Binary files /dev/null and b/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-1024x1024@1x 1.png differ diff --git a/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-1024x1024@1x 2.png b/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-1024x1024@1x 2.png new file mode 100644 index 0000000..16a1e0d Binary files /dev/null and b/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-1024x1024@1x 2.png differ diff --git a/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-1024x1024@1x.png b/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-1024x1024@1x.png new file mode 100644 index 0000000..16a1e0d Binary files /dev/null and b/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-1024x1024@1x.png differ diff --git a/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-128x128@1x.png b/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-128x128@1x.png new file mode 100644 index 0000000..2638a5a Binary files /dev/null and b/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-128x128@1x.png differ diff --git a/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-128x128@2x.png b/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-128x128@2x.png new file mode 100644 index 0000000..de451b8 Binary files /dev/null and b/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-128x128@2x.png differ diff --git a/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-16x16@1x.png b/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-16x16@1x.png new file mode 100644 index 0000000..d0034a8 Binary files /dev/null and b/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-16x16@1x.png differ diff --git a/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-16x16@2x.png b/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-16x16@2x.png new file mode 100644 index 0000000..339be09 Binary files /dev/null and b/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-16x16@2x.png differ diff --git a/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-256x256@1x.png b/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-256x256@1x.png new file mode 100644 index 0000000..209d1ee Binary files /dev/null and b/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-256x256@1x.png differ diff --git a/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-256x256@2x.png b/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-256x256@2x.png new file mode 100644 index 0000000..26f00aa Binary files /dev/null and b/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-256x256@2x.png differ diff --git a/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-32x32@1x.png b/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-32x32@1x.png new file mode 100644 index 0000000..51ae5c4 Binary files /dev/null and b/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-32x32@1x.png differ diff --git a/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-32x32@2x.png b/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-32x32@2x.png new file mode 100644 index 0000000..1530248 Binary files /dev/null and b/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-32x32@2x.png differ diff --git a/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-512x512@1x.png b/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-512x512@1x.png new file mode 100644 index 0000000..5f4dd4c Binary files /dev/null and b/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-512x512@1x.png differ diff --git a/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-512x512@2x.png b/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-512x512@2x.png new file mode 100644 index 0000000..1a4303d Binary files /dev/null and b/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Bemol-512x512@2x.png differ diff --git a/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..ff3ec43 --- /dev/null +++ b/Shared/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "filename" : "Bemol-1024x1024@1x.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Bemol-1024x1024@1x 1.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "filename" : "Bemol-1024x1024@1x 2.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "filename" : "Bemol-16x16@1x.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "filename" : "Bemol-16x16@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "filename" : "Bemol-32x32@1x.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "filename" : "Bemol-32x32@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "filename" : "Bemol-128x128@1x.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "filename" : "Bemol-128x128@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "filename" : "Bemol-256x256@1x.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "filename" : "Bemol-256x256@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "filename" : "Bemol-512x512@1x.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "filename" : "Bemol-512x512@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Bemol/Resources/Assets.xcassets/Contents.json b/Shared/Resources/Assets.xcassets/Contents.json similarity index 100% rename from Bemol/Resources/Assets.xcassets/Contents.json rename to Shared/Resources/Assets.xcassets/Contents.json diff --git a/Bemol/Resources/Assets.xcassets/amber.colorset/Contents.json b/Shared/Resources/Assets.xcassets/amber.colorset/Contents.json similarity index 100% rename from Bemol/Resources/Assets.xcassets/amber.colorset/Contents.json rename to Shared/Resources/Assets.xcassets/amber.colorset/Contents.json diff --git a/Bemol/Resources/Assets.xcassets/button-foreground.colorset/Contents.json b/Shared/Resources/Assets.xcassets/button-foreground.colorset/Contents.json similarity index 100% rename from Bemol/Resources/Assets.xcassets/button-foreground.colorset/Contents.json rename to Shared/Resources/Assets.xcassets/button-foreground.colorset/Contents.json diff --git a/Bemol/Resources/Assets.xcassets/dark.colorset/Contents.json b/Shared/Resources/Assets.xcassets/dark.colorset/Contents.json similarity index 100% rename from Bemol/Resources/Assets.xcassets/dark.colorset/Contents.json rename to Shared/Resources/Assets.xcassets/dark.colorset/Contents.json diff --git a/Bemol/Resources/Assets.xcassets/darkAmber.colorset/Contents.json b/Shared/Resources/Assets.xcassets/darkAmber.colorset/Contents.json similarity index 100% rename from Bemol/Resources/Assets.xcassets/darkAmber.colorset/Contents.json rename to Shared/Resources/Assets.xcassets/darkAmber.colorset/Contents.json diff --git a/Bemol/Resources/Assets.xcassets/disabled-back-key.colorset/Contents.json b/Shared/Resources/Assets.xcassets/disabled-back-key.colorset/Contents.json similarity index 100% rename from Bemol/Resources/Assets.xcassets/disabled-back-key.colorset/Contents.json rename to Shared/Resources/Assets.xcassets/disabled-back-key.colorset/Contents.json diff --git a/Bemol/Resources/Assets.xcassets/key-bed.colorset/Contents.json b/Shared/Resources/Assets.xcassets/key-bed.colorset/Contents.json similarity index 100% rename from Bemol/Resources/Assets.xcassets/key-bed.colorset/Contents.json rename to Shared/Resources/Assets.xcassets/key-bed.colorset/Contents.json diff --git a/Bemol/Resources/Assets.xcassets/overlay.colorset/Contents.json b/Shared/Resources/Assets.xcassets/overlay.colorset/Contents.json similarity index 100% rename from Bemol/Resources/Assets.xcassets/overlay.colorset/Contents.json rename to Shared/Resources/Assets.xcassets/overlay.colorset/Contents.json diff --git a/Bemol/Resources/Assets.xcassets/tooltip-text.colorset/Contents.json b/Shared/Resources/Assets.xcassets/tooltip-text.colorset/Contents.json similarity index 100% rename from Bemol/Resources/Assets.xcassets/tooltip-text.colorset/Contents.json rename to Shared/Resources/Assets.xcassets/tooltip-text.colorset/Contents.json diff --git a/Bemol/Resources/Assets.xcassets/tooltip.colorset/Contents.json b/Shared/Resources/Assets.xcassets/tooltip.colorset/Contents.json similarity index 100% rename from Bemol/Resources/Assets.xcassets/tooltip.colorset/Contents.json rename to Shared/Resources/Assets.xcassets/tooltip.colorset/Contents.json diff --git a/Bemol/Resources/Localizable.xcstrings b/Shared/Resources/Localizable.xcstrings similarity index 88% rename from Bemol/Resources/Localizable.xcstrings rename to Shared/Resources/Localizable.xcstrings index 5b4724d..d4377b8 100644 --- a/Bemol/Resources/Localizable.xcstrings +++ b/Shared/Resources/Localizable.xcstrings @@ -24,6 +24,119 @@ } } }, + "accuracyInLevel" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your performance in this level" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre performance dans ce niveau" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Din prestanda i den här nivån" + } + } + } + }, + "accuracyInLevelWithCTA" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your performance in this level. Click to see a breakdown per note." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre performance dans ce niveau. Cliquez pour voir votre performance par note." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Din prestanda i den här nivån. Tryck för att se din prestanda per ton." + } + } + } + }, + "accuracyInSession" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your accuracy in the current practice session" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre performance dans la session en cours" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Din prestanda i den pågående sessionen" + } + } + } + }, + "accuracyInSessionWithCTA" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your accuracy in the current practice session. Click to see a breakdown per note." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre performance dans la session en cours. Cliquez pour voir votre performance par note." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Din prestanda i den pågående sessionen. Tryck för att se din prestanda per ton." + } + } + } + }, + "anErrorOccurred" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "We’re really sorry but an error occurred. Please restart the app. Or … enjoy the piano below." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Désolé, une erreur s’est produite. Redémarrez l’application ou … amusez-vous ci-dessous." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ett fel uppstod. Vänligen starta om appen. Eller … njut av pianot nedan." + } + } + } + }, "B" : { "extractionState" : "manual", "localizations" : { @@ -47,6 +160,29 @@ } } }, + "bemol" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bemol" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bemol" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bemol" + } + } + } + }, "C" : { "extractionState" : "manual", "localizations" : { @@ -231,6 +367,29 @@ } } }, + "earTraining" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ear training" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Formation auditive" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gehörsträning" + } + } + } + }, "F" : { "extractionState" : "manual", "localizations" : { @@ -329,19 +488,19 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Go to the C major level" + "value" : "Go to the first C major level" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Sélectionner le niveau Do majeur" + "value" : "Sélectionner le premier niveau Do majeur" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "Välj den C dur nivån" + "value" : "Välj den första C dur nivån" } } } @@ -1392,19 +1551,19 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "At first, you will train with only the first 4 notes of the scale. Once you get at least 95% accuracy in a level, you can move to the next one which will introduce new notes." + "value" : "At first, you will train with only the first 4 notes of the scale. Once you get at least 95 percent accuracy in a level, you can move to the next one which will introduce new notes." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Au début, vous allez pratiquer avec seulement les 4 premières notes de la gamme. Lorsque vous avez au moins 95% de précision dans un niveau, vous pouvez passer au suivant. Ce dernier introduira de nouvelles notes." + "value" : "Au début, vous allez pratiquer avec seulement les 4 premières notes de la gamme. Lorsque vous avez au moins 95 pour-cent de précision dans un niveau, vous pouvez passer au suivant. Ce dernier introduira de nouvelles notes." } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "I början tränar du bara med de första 4 tonerna i skalan. När du når 95% i en nivå kan du gå till nästa nivå. Vilken kommer att introducera nya toner." + "value" : "I början tränar du bara med de första 4 tonerna i skalan. När du når 95 procent i en nivå kan du gå till nästa nivå. Vilken kommer att introducera nya toner." } } } @@ -1586,5 +1745,5 @@ } } }, - "version" : "1.0" + "version" : "1.2" } \ No newline at end of file diff --git a/Bemol/Screens/AccuracyScreen.swift b/Shared/Screens/AccuracyScreen.swift similarity index 91% rename from Bemol/Screens/AccuracyScreen.swift rename to Shared/Screens/AccuracyScreen.swift index 6d2fca0..3394e25 100644 --- a/Bemol/Screens/AccuracyScreen.swift +++ b/Shared/Screens/AccuracyScreen.swift @@ -17,7 +17,14 @@ /// import Foundation + +#if os(macOS) +import AppKit +#endif + +#if os(iOS) import UIKit +#endif struct AccuracyScreenDelegate { let didPressDone: () -> Void @@ -38,10 +45,10 @@ struct AccuracyScreenState { @MainActor final class AccuracyScreen { // MARK: - Views - + private lazy var titleBar: TitleBar = { let bar = TitleBar() - bar.translatesAutoresizingMaskIntoConstraints = false + bar.setUp() bar.isCancelButtonHidden = true bar.delegate = TitleBarDelegate( didPressCancelButton: { [weak self] in self?.didPressCancelButton() }, @@ -53,7 +60,7 @@ final class AccuracyScreen { private lazy var keyboardView: KeyboardView = { let keyboardView = KeyboardView(range: 1...2) - keyboardView.translatesAutoresizingMaskIntoConstraints = false + keyboardView.setUp() keyboardView.setEnabledForAllKeys(false) keyboardView.setTintForAllNotes(nil) keyboardView.isScrollEnabled = false @@ -90,19 +97,20 @@ final class AccuracyScreen { keyboardView.setEnabled(true, for: (state?.activeNotes ?? [])) } - titleBar.title = state?.context == .level + titleBar.title = + state?.context == .level ? AttributedString(localized: "levelAccuracy") : AttributedString(localized: "sessionAccuracy") } } - lazy var view: UIView = { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false + lazy var view: View = { + let view = View() + view.setUp() view.addSubview(titleBar) view.addSubview(keyboardView) - NSLayoutConstraint.activate([ + LayoutConstraint.activate([ titleBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), titleBar.topAnchor.constraint(equalTo: view.topAnchor), titleBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), @@ -122,13 +130,13 @@ final class AccuracyScreen { // MARK: - Properties - private let notePlayer: NotePlayer + private let notePlayer: any NotePlayer private var accuracy: [Note: Double] = [:] { didSet { for (note, percent) in accuracy { keyboardView.setTint( - UIColor.color(for: percent), + Color.color(for: percent), percent: percent, for: note ) @@ -142,13 +150,18 @@ final class AccuracyScreen { // MARK: - Initialization - init(notePlayer: NotePlayer) { + init(notePlayer: any NotePlayer) { self.notePlayer = notePlayer } // MARK: - Private Helpers private func titleBarHeightMultiplier() -> CGFloat { +#if os(macOS) + 0.10 +#endif + +#if os(iOS) switch ( titleBar.traitCollection.verticalSizeClass, titleBar.traitCollection.horizontalSizeClass @@ -158,6 +171,7 @@ final class AccuracyScreen { default: 0.20 } +#endif } } diff --git a/Bemol/Screens/ErrorScreen.swift b/Shared/Screens/ErrorScreen.swift similarity index 85% rename from Bemol/Screens/ErrorScreen.swift rename to Shared/Screens/ErrorScreen.swift index 5ec2e76..3f77a29 100644 --- a/Bemol/Screens/ErrorScreen.swift +++ b/Shared/Screens/ErrorScreen.swift @@ -17,7 +17,14 @@ /// import Foundation + +#if os(macOS) +import AppKit +#endif + +#if os(iOS) import UIKit +#endif @MainActor final class ErrorScreen { @@ -25,7 +32,7 @@ final class ErrorScreen { private lazy var titleBar: TitleBar = { let bar = TitleBar() - bar.translatesAutoresizingMaskIntoConstraints = false + bar.setUp() bar.isCancelButtonHidden = true bar.isDoneButtonHidden = true @@ -34,7 +41,7 @@ final class ErrorScreen { private lazy var keyboardView: KeyboardView = { let keyboardView = KeyboardView(range: 1...2) - keyboardView.translatesAutoresizingMaskIntoConstraints = false + keyboardView.setUp() keyboardView.setEnabledForAllKeys(true) keyboardView.setTintForAllNotes(nil) keyboardView.isScrollEnabled = false @@ -46,13 +53,13 @@ final class ErrorScreen { return keyboardView }() - lazy var view: UIView = { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false + lazy var view: View = { + let view = View() + view.setUp() view.addSubview(titleBar) view.addSubview(keyboardView) - NSLayoutConstraint.activate([ + LayoutConstraint.activate([ titleBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), titleBar.topAnchor.constraint(equalTo: view.topAnchor), titleBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), @@ -72,31 +79,36 @@ final class ErrorScreen { // MARK: - API - var error: Error? { + var error: (any Error)? { didSet { - if let error { - titleBar.title = AttributedString(error.localizedDescription) + if error != nil { + titleBar.title = AttributedString(localized: "anErrorOccurred") } } } // MARK: - Properties - private let notePlayer: NotePlayer - private let colors: [UIColor] = [ + private let notePlayer: any NotePlayer + private let colors: [Color] = [ .systemGreen, .systemTeal, .systemCyan, .systemBlue, .systemYellow, .systemPink, .systemPurple, - .systemMint, .systemCyan, .systemBrown + .systemMint, .systemCyan, .systemBrown, ] // MARK: - Initialization - init(notePlayer: NotePlayer) { + init(notePlayer: any NotePlayer) { self.notePlayer = notePlayer } // MARK: - Private Helpers private func titleBarHeightMultiplier() -> CGFloat { +#if os(macOS) + 0.10 +#endif + +#if os(iOS) switch ( titleBar.traitCollection.verticalSizeClass, titleBar.traitCollection.horizontalSizeClass @@ -106,6 +118,7 @@ final class ErrorScreen { default: 0.20 } +#endif } } diff --git a/Bemol/Screens/LevelEditorScreen.swift b/Shared/Screens/LevelEditorScreen.swift similarity index 94% rename from Bemol/Screens/LevelEditorScreen.swift rename to Shared/Screens/LevelEditorScreen.swift index 5ab53a3..6eb40d2 100644 --- a/Bemol/Screens/LevelEditorScreen.swift +++ b/Shared/Screens/LevelEditorScreen.swift @@ -17,7 +17,14 @@ /// import Foundation + +#if os(macOS) +import AppKit +#endif + +#if os(iOS) import UIKit +#endif struct LevelEditorScreenDelegate { let didCancel: () -> Void @@ -36,7 +43,7 @@ final class LevelEditorScreen { private lazy var titleBar: TitleBar = { let bar = TitleBar() - bar.translatesAutoresizingMaskIntoConstraints = false + bar.setUp() bar.title = AttributedString(String(localized: "selectNotes")) bar.delegate = TitleBarDelegate( didPressCancelButton: { [weak self] in self?.didPressCancelButton() }, @@ -48,7 +55,7 @@ final class LevelEditorScreen { private lazy var keyboardView: KeyboardView = { let keyboardView = KeyboardView(range: range) - keyboardView.translatesAutoresizingMaskIntoConstraints = false + keyboardView.setUp() keyboardView.isScrollEnabled = false keyboardView.delegate = KeyboardViewDelegate( didPressNote: { [weak self] in self?.didPressNote($0) }, @@ -85,9 +92,9 @@ final class LevelEditorScreen { } } - lazy var view: UIView = { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false + lazy var view: View = { + let view = View() + view.setUp() view.addSubview(titleBar) view.addSubview(keyboardView) @@ -112,12 +119,12 @@ final class LevelEditorScreen { // MARK: - Properties private let range: ClosedRange = 1...2 - private let notePlayer: NotePlayer + private let notePlayer: any NotePlayer private var selectedNotes: [Note] = [] // MARK: - Initialization - init(notePlayer: NotePlayer) { + init(notePlayer: any NotePlayer) { self.notePlayer = notePlayer } @@ -149,6 +156,11 @@ final class LevelEditorScreen { } private func titleBarHeightMultiplier() -> CGFloat { +#if os(macOS) + 0.10 +#endif + +#if os(iOS) switch ( titleBar.traitCollection.verticalSizeClass, titleBar.traitCollection.horizontalSizeClass @@ -158,6 +170,7 @@ final class LevelEditorScreen { default: 0.20 } +#endif } } diff --git a/Bemol/Screens/MainScreen.swift b/Shared/Screens/MainScreen.swift similarity index 92% rename from Bemol/Screens/MainScreen.swift rename to Shared/Screens/MainScreen.swift index 6ac08f5..8cfe8e5 100644 --- a/Bemol/Screens/MainScreen.swift +++ b/Shared/Screens/MainScreen.swift @@ -17,7 +17,14 @@ /// import Foundation + +#if os(macOS) +import AppKit +#endif + +#if os(iOS) import UIKit +#endif struct MainScreenDelegate { let didPressHomeButton: () -> Void @@ -39,7 +46,7 @@ final class MainScreen { private lazy var keyboardView: KeyboardView = { let keyboard = KeyboardView(range: 1...2) - keyboard.translatesAutoresizingMaskIntoConstraints = false + keyboard.setUp() keyboard.setEnabledForAllKeys(false) keyboard.setTintForAllNotes(nil) keyboard.isScrollEnabled = false @@ -54,7 +61,7 @@ final class MainScreen { private lazy var navBar: NavBar = { let bar = NavBar() - bar.translatesAutoresizingMaskIntoConstraints = false + bar.setUp() bar.delegate = NavBarDelegate( didPressHomeButton: { [weak self] in self?.didPressHomeButton() }, didPressRandomButton: { [weak self] in self?.didPressRandomButton() }, @@ -63,21 +70,21 @@ final class MainScreen { didPressConfigureButton: { [weak self] in self?.didPressConfigureButton() }, didPressStartStopButton: { [weak self] in self?.didPressStartStopButton() }, didPressRepeatButton: { [weak self] in self?.didPressRepeatButton() }, - didPressProgressButton: { [weak self] in self?.didPressProgressButton() }, + didPressProgressButton: { [weak self] in self?.didPressProgressButton() }, didDismissTip: { [weak self] in self?.didDismissTip() } ) return bar }() - lazy var view: UIView = { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false + lazy var view: View = { + let view = View() + view.setUp() view.addSubview(navBar) view.addSubview(keyboardView) - NSLayoutConstraint.activate([ + LayoutConstraint.activate([ navBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), navBar.topAnchor.constraint(equalTo: view.topAnchor), navBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), @@ -107,7 +114,10 @@ final class MainScreen { let activeNotes = state?.activeNotes ?? [] let oldActiveNotes = oldValue?.activeNotes ?? [] +#if os(iOS) navBar.state = state?.navBarState +#endif + keyboardView.isUserInteractionEnabled = state?.isKeyboardEnabled ?? true if oldValue == nil || key != oldKey || activeNotes != oldActiveNotes { @@ -135,12 +145,20 @@ final class MainScreen { // MARK: - Private Helpers private func navBarHeightMultiplier() -> CGFloat { +#if os(macOS) + // Crude way to hide the nav bar on macOS. + // Hiding because we use the native NSToolbar instead on macOS. + 0 +#endif + +#if os(iOS) switch (navBar.traitCollection.verticalSizeClass, navBar.traitCollection.horizontalSizeClass) { case (.regular, .regular): 0.10 default: 0.20 } +#endif } } diff --git a/Bemol/Screens/MainScreenState.swift b/Shared/Screens/MainScreenState.swift similarity index 96% rename from Bemol/Screens/MainScreenState.swift rename to Shared/Screens/MainScreenState.swift index 6b6f67e..73b9f37 100644 --- a/Bemol/Screens/MainScreenState.swift +++ b/Shared/Screens/MainScreenState.swift @@ -1,5 +1,4 @@ import Foundation -import UIKit struct MainScreenState { var isLoading: Bool @@ -20,7 +19,7 @@ struct MainScreenState { var accuracyRingAccessibilityText: String? var isKeyboardEnabled: Bool var activeNotes: [Note] - var highlightedNote: (Note, UIColor)? + var highlightedNote: (Note, Color)? var tip: Tip? } diff --git a/Bemol/Services/CyclicPracticeManager.swift b/Shared/Services/Implementations/CyclicPracticeManager.swift similarity index 84% rename from Bemol/Services/CyclicPracticeManager.swift rename to Shared/Services/Implementations/CyclicPracticeManager.swift index 4a20b91..6661378 100644 --- a/Bemol/Services/CyclicPracticeManager.swift +++ b/Shared/Services/Implementations/CyclicPracticeManager.swift @@ -2,7 +2,7 @@ /// CyclicPracticeManager.swift /// Bemol /// -/// Copyright 2025 Faiçal Tchirou +/// Copyright 2026 Faiçal Tchirou /// /// Bemol is free software: you can redistribute it and/or modify it under the terms of /// the GNU General Public License as published by the Free Software Foundation, either version 3 @@ -18,17 +18,17 @@ import Foundation -actor CyclicPracticeManager: PracticeManager { +final class CyclicPracticeManager: PracticeManager { enum Error: Swift.Error { case noSessionInProgress case unexpected } private let preferenceKey = "practice.level.cursor" - private let storage: SessionStorage - private let levelGenerator: LevelGenerator - private let noteResolutionGenerator: NoteResolutionGenerator - private let preferences: Preferences + private let storage: any SessionStorage + private let levelGenerator: any LevelGenerator + private let noteResolutionGenerator: any NoteResolutionGenerator + private let preferences: any Preferences private var allLevels: [Level] = [] private var cursor = -1 @@ -40,10 +40,10 @@ actor CyclicPracticeManager: PracticeManager { private var lastAnsweredQuestion: Question? = nil init( - storage: SessionStorage, - levelGenerator: LevelGenerator, - noteResolutionGenerator: NoteResolutionGenerator, - preferences: Preferences + storage: any SessionStorage, + levelGenerator: any LevelGenerator, + noteResolutionGenerator: any NoteResolutionGenerator, + preferences: any Preferences ) { self.storage = storage self.levelGenerator = levelGenerator @@ -60,7 +60,7 @@ actor CyclicPracticeManager: PracticeManager { decrementCursor() return try await moveToLevelAtCursor() } - + func moveToNextLevel() async throws -> Level { incrementCursor() return try await moveToLevelAtCursor() @@ -80,7 +80,7 @@ actor CyclicPracticeManager: PracticeManager { currentSession = Session(timestamp: Date.now.timeIntervalSince1970, score: [:]) return currentSession! } - + func stopCurrentSession() async throws -> Level { guard let level = currentLevel else { throw Error.unexpected } guard let session = currentSession else { throw Error.noSessionInProgress } @@ -111,26 +111,27 @@ actor CyclicPracticeManager: PracticeManager { let note = currentNotes[currentQuestionIndex] - let resolution = if level.isMajor { - noteResolutionGenerator.resolution( - for: note, - inMajorKey: level.key, - includingChromaticNotes: level.isChromatic - ) - } else { - noteResolutionGenerator.resolution( - for: note, - inMinorKey: level.key, - includingChromaticNotes: level.isChromatic - ) - } + let resolution = + if level.isMajor { + noteResolutionGenerator.resolution( + for: note, + inMajorKey: level.key, + includingChromaticNotes: level.isChromatic + ) + } else { + noteResolutionGenerator.resolution( + for: note, + inMinorKey: level.key, + includingChromaticNotes: level.isChromatic + ) + } let question = Question(answer: note, resolution: resolution) self.currentQuestion = question return question } - + func logCorrectAnswer(_ note: Note, for question: Question) async throws -> Session { guard var session = currentSession else { throw Error.noSessionInProgress @@ -145,7 +146,7 @@ actor CyclicPracticeManager: PracticeManager { var score = session.score if let noteScore = score[question.answer] { - score[question.answer] = (noteScore.0 + 1, noteScore.1) + score[question.answer] = Score(correct: noteScore.correct + 1, wrong: noteScore.wrong) session = Session(timestamp: session.timestamp, score: score) self.currentSession = session @@ -153,14 +154,14 @@ actor CyclicPracticeManager: PracticeManager { return session } - score[question.answer] = (1, 0) + score[question.answer] = Score(correct: 1, wrong: 0) session = Session(timestamp: session.timestamp, score: score) self.currentSession = session return session } - + func logWrongAnswer(_ note: Note, for question: Question) async throws -> Session { guard var session = currentSession else { throw Error.noSessionInProgress @@ -175,7 +176,7 @@ actor CyclicPracticeManager: PracticeManager { var score = session.score if let noteScore = score[question.answer] { - score[question.answer] = (noteScore.0, noteScore.1 + 1) + score[question.answer] = Score(correct: noteScore.correct, wrong: noteScore.wrong + 1) session = Session(timestamp: session.timestamp, score: score) self.currentSession = session @@ -183,7 +184,7 @@ actor CyclicPracticeManager: PracticeManager { return session } - score[question.answer] = (0, 1) + score[question.answer] = Score(correct: 0, wrong: 1) session = Session(timestamp: session.timestamp, score: score) self.currentSession = session @@ -231,7 +232,7 @@ actor CyclicPracticeManager: PracticeManager { for key in keys { for includesChromatics in [false, true] { - for spansMultipleOctaves in [false, /* true */] { + for spansMultipleOctaves in [false /* true */] { for range in [NoteRange.firstHalfOfOctave, .secondHalfOfOctave, .entireOctave] { levels.append( levelGenerator.makeLevel( @@ -249,7 +250,7 @@ actor CyclicPracticeManager: PracticeManager { } for includesChromatics in [false, true] { - for spansMultipleOctaves in [false, /* true */] { + for spansMultipleOctaves in [false /* true */] { for range in [NoteRange.firstHalfOfOctave, .secondHalfOfOctave, .entireOctave] { levels.append( levelGenerator.makeLevel( diff --git a/Bemol/Services/LevelGenerator.swift b/Shared/Services/Implementations/DiatonicLevelGenerator.swift similarity index 92% rename from Bemol/Services/LevelGenerator.swift rename to Shared/Services/Implementations/DiatonicLevelGenerator.swift index fb4bd2c..86454f4 100644 --- a/Bemol/Services/LevelGenerator.swift +++ b/Shared/Services/Implementations/DiatonicLevelGenerator.swift @@ -1,8 +1,8 @@ /// -/// LevelGenerator.swift +/// DiatonicLevelGenerator.swift /// Bemol /// -/// Copyright 2025 Faiçal Tchirou +/// Copyright 2026 Faiçal Tchirou /// /// Bemol is free software: you can redistribute it and/or modify it under the terms of /// the GNU General Public License as published by the Free Software Foundation, either version 3 @@ -18,26 +18,6 @@ import Foundation -protocol LevelGenerator { - func makeLevel( - withId id: Int, - inMajorKey key: NoteName, - spanningMultipleOctaves spansMultipleOctaves: Bool, - includingChromaticNotes includeChromaticNotes: Bool, - limitingNotesTo range: NoteRange - ) -> Level - - func makeLevel( - withId id: Int, - inMinorKey key: NoteName, - spanningMultipleOctaves spansMultipleOctaves: Bool, - includingChromaticNotes includeChromaticNotes: Bool, - limitingNotesTo range: NoteRange - ) -> Level -} - -// MARK: - - struct DiatonicLevelGenerator: LevelGenerator { func makeLevel( withId id: Int, @@ -253,7 +233,7 @@ struct DiatonicLevelGenerator: LevelGenerator { [0, 0, 0], [0, 1, 2], [-1, -2, 0], - [0, 0, 0] + [0, 0, 0], ] ) } @@ -266,7 +246,7 @@ struct DiatonicLevelGenerator: LevelGenerator { [0, 0, 0], [0, 2, 1], [-1, -1, 0], - [0, 0, 0] + [0, 0, 0], ] ) } diff --git a/Bemol/Services/NoteResolutionGenerator.swift b/Shared/Services/Implementations/DiatonicNoteResolutionGenerator.swift similarity index 86% rename from Bemol/Services/NoteResolutionGenerator.swift rename to Shared/Services/Implementations/DiatonicNoteResolutionGenerator.swift index 9ff1672..a9e07fb 100644 --- a/Bemol/Services/NoteResolutionGenerator.swift +++ b/Shared/Services/Implementations/DiatonicNoteResolutionGenerator.swift @@ -1,8 +1,8 @@ /// -/// NoteResolutionGenerator.swift +/// DiatonicNoteResolutionGenerator.swift /// Bemol /// -/// Copyright 2025 Faiçal Tchirou +/// Copyright 2026 Faiçal Tchirou /// /// Bemol is free software: you can redistribute it and/or modify it under the terms of /// the GNU General Public License as published by the Free Software Foundation, either version 3 @@ -18,22 +18,6 @@ import Foundation -protocol NoteResolutionGenerator { - func resolution( - for note: Note, - inMajorKey key: NoteName, - includingChromaticNotes includeChromaticNotes: Bool - ) -> Resolution - - func resolution( - for note: Note, - inMinorKey key: NoteName, - includingChromaticNotes includeChromaticNotes: Bool - ) -> Resolution -} - -// MARK: - - struct DiatonicNoteResolutionGenerator: NoteResolutionGenerator { func resolution( for note: Note, @@ -60,8 +44,8 @@ struct DiatonicNoteResolutionGenerator: NoteResolutionGenerator { } } -private extension Note { - func resolutionInMajor(_ key: NoteName) -> Resolution { +extension Note { + fileprivate func resolutionInMajor(_ key: NoteName) -> Resolution { var firstPattern = [0, 2, 4, 5] var secondPattern = [7, 9, 11] let index = name.index(inKey: key) @@ -79,7 +63,7 @@ private extension Note { return resolution(key: key, firstPattern: firstPattern, secondPattern: secondPattern) } - func resolutionInMajorChromatics(_ key: NoteName) -> Resolution { + fileprivate func resolutionInMajorChromatics(_ key: NoteName) -> Resolution { var firstPattern = [0, 2, 4, 5] var secondPattern = [7, 9, 11] let index = name.index(inKey: key) @@ -97,7 +81,7 @@ private extension Note { return resolution(key: key, firstPattern: firstPattern, secondPattern: secondPattern) } - func resolutionInMinor(_ key: NoteName) -> Resolution { + fileprivate func resolutionInMinor(_ key: NoteName) -> Resolution { var firstPattern = [0, 2, 3, 5] var secondPattern = [7, 8, 11] let index = name.index(inKey: key) @@ -115,7 +99,7 @@ private extension Note { return resolution(key: key, firstPattern: firstPattern, secondPattern: secondPattern) } - func resolutionInMinorChromatics(_ key: NoteName) -> Resolution { + fileprivate func resolutionInMinorChromatics(_ key: NoteName) -> Resolution { var firstPattern = [0, 2, 3, 5] var secondPattern = [7, 8, 11] let index = name.index(inKey: key) @@ -133,7 +117,7 @@ private extension Note { return resolution(key: key, firstPattern: firstPattern, secondPattern: secondPattern) } - func resolution( + fileprivate func resolution( key: NoteName, firstPattern: [Int], secondPattern: [Int] diff --git a/Bemol/Services/SessionStorage.swift b/Shared/Services/Implementations/FileSessionStorage.swift similarity index 87% rename from Bemol/Services/SessionStorage.swift rename to Shared/Services/Implementations/FileSessionStorage.swift index d3d022c..15c7967 100644 --- a/Bemol/Services/SessionStorage.swift +++ b/Shared/Services/Implementations/FileSessionStorage.swift @@ -1,8 +1,8 @@ /// -/// SessionStorage.swift +/// FileSessionStorage.swift /// Bemol /// -/// Copyright 2025 Faiçal Tchirou +/// Copyright 2026 Faiçal Tchirou /// /// Bemol is free software: you can redistribute it and/or modify it under the terms of /// the GNU General Public License as published by the Free Software Foundation, either version 3 @@ -15,18 +15,10 @@ /// You should have received a copy of the GNU General Public License along with Foobar. /// If not, see . /// -/// import Foundation -protocol SessionStorage: Actor { - func saveSession(_ session: Session, level: Level) async throws - func loadSessions(for level: Level) async throws -> [Session] -} - -// MARK: - - -actor FileSessionStorage: SessionStorage { +final class FileSessionStorage: SessionStorage { enum Error: Swift.Error { case unexpected } @@ -50,8 +42,8 @@ actor FileSessionStorage: SessionStorage { let score = session.score .map { ($0.key.name, $0.key.octave, $0.value.correct, $0.value.wrong) } - .sorted(by: { $0.0 < $1.0 }) - .map { "\($0.0.toString()):\($0.1):\($0.2):\($0.3)"} + .sorted(by: { $0.0 < $1.0 }) + .map { "\($0.0.toString()):\($0.1):\($0.2):\($0.3)" } .joined(separator: ",") guard let line = "\(session.timestamp);\(score)\n".data(using: .utf8) else { @@ -88,7 +80,7 @@ actor FileSessionStorage: SessionStorage { } let timestamp = TimeInterval(components[0]) ?? 0 - var score: [Note: (Int, Int)] = [:] + var score: [Note: Score] = [:] for stat in components[1].split(separator: ",") { let noteAndValues = stat.split(separator: ":") @@ -106,7 +98,7 @@ actor FileSessionStorage: SessionStorage { continue } - score[Note(name: noteName, octave: noteOctave)] = (correct, wrong) + score[Note(name: noteName, octave: noteOctave)] = Score(correct: correct, wrong: wrong) } if !score.isEmpty { @@ -147,8 +139,8 @@ actor FileSessionStorage: SessionStorage { } } -private extension NoteName { - static func parse(_ substring: Substring, for level: Int) -> NoteName? { +extension NoteName { + fileprivate static func parse(_ substring: Substring, for level: Int) -> NoteName? { switch substring.lowercased() { case "c": .c case "csharp", "dflat": .cSharp @@ -166,7 +158,7 @@ private extension NoteName { } } - func toString() -> String { + fileprivate func toString() -> String { switch self { case .c: "c" case .cSharp: "csharp" diff --git a/Bemol/Services/MIDINotePlayer.swift b/Shared/Services/Implementations/MIDINotePlayer.swift similarity index 92% rename from Bemol/Services/MIDINotePlayer.swift rename to Shared/Services/Implementations/MIDINotePlayer.swift index 3dd25bc..018f032 100644 --- a/Bemol/Services/MIDINotePlayer.swift +++ b/Shared/Services/Implementations/MIDINotePlayer.swift @@ -2,7 +2,7 @@ /// MIDINotePlayer.swift /// Bemol /// -/// Copyright 2025 Faiçal Tchirou +/// Copyright 2026 Faiçal Tchirou /// /// Bemol is free software: you can redistribute it and/or modify it under the terms of /// the GNU General Public License as published by the Free Software Foundation, either version 3 @@ -19,7 +19,7 @@ import AVFoundation import Foundation -actor MIDINotePlayer: NotePlayer { +final class MIDINotePlayer: NotePlayer { enum Error: Swift.Error { case couldNotLoadSoundFont } @@ -49,7 +49,10 @@ actor MIDINotePlayer: NotePlayer { func prepareToPlay() async throws { try loadSoundFont() + +#if os(iOS) try AVAudioSession.sharedInstance().setCategory(.playback, options: .duckOthers) +#endif chordTrack = sequencer.createAndAppendTrack() noteTrack = sequencer.createAndAppendTrack() @@ -66,7 +69,7 @@ actor MIDINotePlayer: NotePlayer { try await Task.sleep(for: .seconds(sequencer.seconds(forBeats: 0.75)), clock: clock) } - + func playCadence(_ cadence: Cadence) async throws { try await Task.sleep(for: .seconds(0.25), clock: clock) @@ -90,13 +93,14 @@ actor MIDINotePlayer: NotePlayer { // MARK: - Private Helpers private func makeMIDINoteEvent(_ note: Note) -> AVMIDINoteEvent { - let key = if note.octave == 0 { - keyNumber(for: note.name, octave: 3) - } else if note.octave == 1 { - keyNumber(for: note.name, octave: 4) - } else { - keyNumber(for: note.name, octave: 5) - } + let key = + if note.octave == 0 { + keyNumber(for: note.name, octave: 3) + } else if note.octave == 1 { + keyNumber(for: note.name, octave: 4) + } else { + keyNumber(for: note.name, octave: 5) + } return AVMIDINoteEvent( channel: 0, diff --git a/Bemol/Services/TipProvider.swift b/Shared/Services/Implementations/OnboardingTipProvider.swift similarity index 95% rename from Bemol/Services/TipProvider.swift rename to Shared/Services/Implementations/OnboardingTipProvider.swift index ef3240c..425424b 100644 --- a/Bemol/Services/TipProvider.swift +++ b/Shared/Services/Implementations/OnboardingTipProvider.swift @@ -1,8 +1,8 @@ /// -/// TipProvider.swift +/// OnboardingTipProvider.swift /// Bemol /// -/// Copyright 2025 Faiçal Tchirou +/// Copyright 2026 Faiçal Tchirou /// /// Bemol is free software: you can redistribute it and/or modify it under the terms of /// the GNU General Public License as published by the Free Software Foundation, either version 3 @@ -15,16 +15,9 @@ /// You should have received a copy of the GNU General Public License along with Foobar. /// If not, see . /// -/// import Foundation -protocol TipProvider { - func nextTip() -> Tip? -} - -// MARK: - OnboardingTipProvider - final class OnboardingTipProvider: TipProvider { private var index = -1 @@ -37,7 +30,7 @@ final class OnboardingTipProvider: TipProvider { return nil } - private var tips = [ + private var tips = [ Tip( target: .startStopButton, title: String(localized: "tip.howItWorks.title"), diff --git a/Shared/Services/Implementations/UserDefaults+Preferences.swift b/Shared/Services/Implementations/UserDefaults+Preferences.swift new file mode 100644 index 0000000..89d4f23 --- /dev/null +++ b/Shared/Services/Implementations/UserDefaults+Preferences.swift @@ -0,0 +1,42 @@ +/// +/// UserDefaults+Preferences.swift +/// Bemol +/// +/// Copyright 2026 Faiçal Tchirou +/// +/// Bemol is free software: you can redistribute it and/or modify it under the terms of +/// the GNU General Public License as published by the Free Software Foundation, either version 3 +/// of the License, or (at your option) any later version. +/// +/// Bemol is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +/// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +/// See the GNU General Public License for more details. +/// +/// You should have received a copy of the GNU General Public License along with Foobar. +/// If not, see . +/// + +import Foundation + +extension UserDefaults: Preferences { + func value(for key: PreferenceKey) -> Int? { + self.value(forKey: key.rawValue) as? Int + } + + func setValue(_ value: Int, for key: PreferenceKey) { + self.setValue(value, forKey: key.rawValue) + } + + func value(for key: PreferenceKey) -> Bool { + if key == .userHasSeenOnboarding { + // Temporarily disable the onboarding process. + return true + } + + return self.value(forKey: key.rawValue) as? Bool ?? false + } + + func setValue(_ value: Bool, for key: PreferenceKey) { + self.setValue(value, forKey: key.rawValue) + } +} diff --git a/BemolUITests/BemolUITestsLaunchTests.swift b/Shared/Services/LevelGenerator.swift similarity index 56% rename from BemolUITests/BemolUITestsLaunchTests.swift rename to Shared/Services/LevelGenerator.swift index ecb218e..b7fbfbf 100644 --- a/BemolUITests/BemolUITestsLaunchTests.swift +++ b/Shared/Services/LevelGenerator.swift @@ -1,5 +1,5 @@ /// -/// AppLoop.swift +/// LevelGenerator.swift /// Bemol /// /// Copyright 2025 Faiçal Tchirou @@ -16,26 +16,22 @@ /// If not, see . /// -import XCTest +import Foundation -final class BemolUITestsLaunchTests: XCTestCase { +protocol LevelGenerator { + func makeLevel( + withId id: Int, + inMajorKey key: NoteName, + spanningMultipleOctaves spansMultipleOctaves: Bool, + includingChromaticNotes includeChromaticNotes: Bool, + limitingNotesTo range: NoteRange + ) -> Level - override class var runsForEachTargetApplicationUIConfiguration: Bool { - true - } - - override func setUpWithError() throws { - continueAfterFailure = false - } - - @MainActor - func testLaunch() throws { - let app = XCUIApplication() - app.launch() - - let attachment = XCTAttachment(screenshot: app.screenshot()) - attachment.name = "Launch Screen" - attachment.lifetime = .keepAlways - add(attachment) - } + func makeLevel( + withId id: Int, + inMinorKey key: NoteName, + spanningMultipleOctaves spansMultipleOctaves: Bool, + includingChromaticNotes includeChromaticNotes: Bool, + limitingNotesTo range: NoteRange + ) -> Level } diff --git a/Bemol/Services/NotePlayer.swift b/Shared/Services/NotePlayer.swift similarity index 96% rename from Bemol/Services/NotePlayer.swift rename to Shared/Services/NotePlayer.swift index 8a1d7a1..bb20a53 100644 --- a/Bemol/Services/NotePlayer.swift +++ b/Shared/Services/NotePlayer.swift @@ -18,7 +18,8 @@ import Foundation -protocol NotePlayer: Actor { +@MainActor +protocol NotePlayer { func prepareToPlay() async throws func playNote(_ note: Note) async throws func playCadence(_ cadence: Cadence) async throws diff --git a/Shared/Services/NoteResolutionGenerator.swift b/Shared/Services/NoteResolutionGenerator.swift new file mode 100644 index 0000000..60f567c --- /dev/null +++ b/Shared/Services/NoteResolutionGenerator.swift @@ -0,0 +1,33 @@ +/// +/// NoteResolutionGenerator.swift +/// Bemol +/// +/// Copyright 2025 Faiçal Tchirou +/// +/// Bemol is free software: you can redistribute it and/or modify it under the terms of +/// the GNU General Public License as published by the Free Software Foundation, either version 3 +/// of the License, or (at your option) any later version. +/// +/// Bemol is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +/// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +/// See the GNU General Public License for more details. +/// +/// You should have received a copy of the GNU General Public License along with Foobar. +/// If not, see . +/// + +import Foundation + +protocol NoteResolutionGenerator { + func resolution( + for note: Note, + inMajorKey key: NoteName, + includingChromaticNotes includeChromaticNotes: Bool + ) -> Resolution + + func resolution( + for note: Note, + inMinorKey key: NoteName, + includingChromaticNotes includeChromaticNotes: Bool + ) -> Resolution +} diff --git a/Bemol/Services/PracticeManager.swift b/Shared/Services/PracticeManager.swift similarity index 97% rename from Bemol/Services/PracticeManager.swift rename to Shared/Services/PracticeManager.swift index 6e1fa9a..e6b1aec 100644 --- a/Bemol/Services/PracticeManager.swift +++ b/Shared/Services/PracticeManager.swift @@ -18,7 +18,8 @@ import Foundation -protocol PracticeManager: Actor { +@MainActor +protocol PracticeManager { func prepareToPractice() async throws func moveToPreviousLevel() async throws -> Level func moveToNextLevel() async throws -> Level diff --git a/Bemol/Services/Preferences.swift b/Shared/Services/Preferences.swift similarity index 69% rename from Bemol/Services/Preferences.swift rename to Shared/Services/Preferences.swift index 1907e39..092f79e 100644 --- a/Bemol/Services/Preferences.swift +++ b/Shared/Services/Preferences.swift @@ -31,23 +31,3 @@ protocol Preferences { func value(for key: PreferenceKey) -> Bool func setValue(_ value: Bool, for key: PreferenceKey) } - -// MARK: - UserDefaults - -extension UserDefaults: Preferences { - func value(for key: PreferenceKey) -> Int? { - self.value(forKey: key.rawValue) as? Int - } - - func setValue(_ value: Int, for key: PreferenceKey) { - self.setValue(value, forKey: key.rawValue) - } - - func value(for key: PreferenceKey) -> Bool { - self.value(forKey: key.rawValue) as? Bool ?? false - } - - func setValue(_ value: Bool, for key: PreferenceKey) { - self.setValue(value, forKey: key.rawValue) - } -} diff --git a/Bemol/Tokens/CornerRadii.swift b/Shared/Services/SessionStorage.swift similarity index 71% rename from Bemol/Tokens/CornerRadii.swift rename to Shared/Services/SessionStorage.swift index 57f7b8a..2f1e9db 100644 --- a/Bemol/Tokens/CornerRadii.swift +++ b/Shared/Services/SessionStorage.swift @@ -1,5 +1,5 @@ /// -/// CornerRadii.swift +/// SessionStorage.swift /// Bemol /// /// Copyright 2025 Faiçal Tchirou @@ -15,13 +15,12 @@ /// You should have received a copy of the GNU General Public License along with Foobar. /// If not, see . /// +/// -import UIKit +import Foundation -public extension CGFloat { - static let cornerRadiusZero: CGFloat = 0 - static let cornerRadiusSm: CGFloat = 4 - static let cornerRadiusMd: CGFloat = 8 - static let cornerRadiusLg: CGFloat = 12 - static let cornerRadiusXl: CGFloat = 16 +@MainActor +protocol SessionStorage { + func saveSession(_ session: Session, level: Level) async throws + func loadSessions(for level: Level) async throws -> [Session] } diff --git a/Bemol/Tokens/Spacings.swift b/Shared/Services/TipProvider.swift similarity index 71% rename from Bemol/Tokens/Spacings.swift rename to Shared/Services/TipProvider.swift index c03171d..bb26a94 100644 --- a/Bemol/Tokens/Spacings.swift +++ b/Shared/Services/TipProvider.swift @@ -1,5 +1,5 @@ /// -/// Spacings.swift +/// TipProvider.swift /// Bemol /// /// Copyright 2025 Faiçal Tchirou @@ -15,15 +15,11 @@ /// You should have received a copy of the GNU General Public License along with Foobar. /// If not, see . /// +/// -import UIKit +import Foundation -public extension CGFloat { - static let spacingXxxs: CGFloat = 1 - static let spacingXxs: CGFloat = 2 - static let spacingXs: CGFloat = 8 - static let spacingSm: CGFloat = 16 - static let spacingMd: CGFloat = 24 - static let spacingLg: CGFloat = 32 +@MainActor +protocol TipProvider: Sendable { + func nextTip() -> Tip? } - diff --git a/Bemol/Tokens/Colors.swift b/Shared/Tokens/Colors.swift similarity index 71% rename from Bemol/Tokens/Colors.swift rename to Shared/Tokens/Colors.swift index 9216f88..ce67fc8 100644 --- a/Bemol/Tokens/Colors.swift +++ b/Shared/Tokens/Colors.swift @@ -16,10 +16,20 @@ /// If not, see . /// +import Foundation + +#if os(macOS) +import AppKit +typealias Color = NSColor +#endif + +#if os(iOS) import UIKit +typealias Color = UIColor +#endif -extension UIColor { - static func color(for progress: Double) -> UIColor { +extension Color { + static func color(for progress: Double) -> Color { return if progress < 0.3 { .systemRed } else if progress < 0.5 { @@ -35,29 +45,37 @@ extension UIColor { } // https://www.advancedswift.com/lighter-and-darker-uicolor-swift/ -extension UIColor { - func lighter(_ componentDelta: CGFloat = 0.1) -> UIColor { +extension Color { + func lighter(_ componentDelta: CGFloat = 0.1) -> Color { return makeColor(componentDelta: componentDelta) } - func darker(_ componentDelta: CGFloat = 0.1) -> UIColor { + func darker(_ componentDelta: CGFloat = 0.1) -> Color { return makeColor(componentDelta: -1 * componentDelta) } - private func makeColor(componentDelta: CGFloat) -> UIColor { + private func makeColor(componentDelta: CGFloat) -> Color { var red: CGFloat = 0 var blue: CGFloat = 0 var green: CGFloat = 0 var alpha: CGFloat = 0 - getRed( + #if os(iOS) + let color = self + #endif + + #if os(macOS) + let color = self.usingColorSpace(.deviceRGB) ?? self + #endif + + color.getRed( &red, green: &green, blue: &blue, alpha: &alpha ) - return UIColor( + return Color( red: add(componentDelta, toComponent: red), green: add(componentDelta, toComponent: green), blue: add(componentDelta, toComponent: blue), @@ -71,8 +89,8 @@ extension UIColor { } // https://graphicdesign.stackexchange.com/a/77747 -extension UIColor { - func bestContrastingColor() -> UIColor { +extension Color { + func bestContrastingColor() -> Color { let (red, green, blue, _) = rgba if (0.2126 * red) + (0.7152 * green) + (0.0722 * blue) > 0.5 { @@ -88,7 +106,15 @@ extension UIColor { var blue: CGFloat = 0 var alpha: CGFloat = 0 - getRed(&red, green: &green, blue: &blue, alpha: &alpha) + #if os(iOS) + let color = self + #endif + + #if os(macOS) + let color = self.usingColorSpace(.deviceRGB) ?? self + #endif + + color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) return (red: red, green: green, blue: blue, alpha: alpha) } diff --git a/BemolUITests/BemolUITests.swift b/Shared/Tokens/CornerRadii.swift similarity index 69% rename from BemolUITests/BemolUITests.swift rename to Shared/Tokens/CornerRadii.swift index cfefff3..04f2526 100644 --- a/BemolUITests/BemolUITests.swift +++ b/Shared/Tokens/CornerRadii.swift @@ -1,5 +1,5 @@ /// -/// BemolUITests.swift +/// CornerRadii.swift /// Bemol /// /// Copyright 2025 Faiçal Tchirou @@ -16,19 +16,12 @@ /// If not, see . /// -import XCTest +import Foundation -final class BemolUITests: XCTestCase { - override func setUpWithError() throws { - continueAfterFailure = false - } - - override func tearDownWithError() throws { - } - - @MainActor - func test() throws { - let app = XCUIApplication() - app.launch() - } +extension CGFloat { + public static let cornerRadiusZero: CGFloat = 0 + public static let cornerRadiusSm: CGFloat = 4 + public static let cornerRadiusMd: CGFloat = 8 + public static let cornerRadiusLg: CGFloat = 12 + public static let cornerRadiusXl: CGFloat = 16 } diff --git a/Shared/Tokens/Fonts.swift b/Shared/Tokens/Fonts.swift new file mode 100644 index 0000000..6747782 --- /dev/null +++ b/Shared/Tokens/Fonts.swift @@ -0,0 +1,60 @@ +/// +/// Fonts.swift +/// Bemol +/// +/// Copyright 2025 Faiçal Tchirou +/// +/// Bemol is free software: you can redistribute it and/or modify it under the terms of +/// the GNU General Public License as published by the Free Software Foundation, either version 3 +/// of the License, or (at your option) any later version. +/// +/// Bemol is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +/// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +/// See the GNU General Public License for more details. +/// +/// You should have received a copy of the GNU General Public License along with Foobar. +/// If not, see . +/// + +import Foundation + +#if os(macOS) +import AppKit +typealias Font = NSFont +#endif + +#if os(iOS) +import UIKit +typealias Font = UIFont +#endif + +extension Font { + static let largeTitle: Font = makeFont(textStyle: .largeTitle, size: 32, weight: .heavy) + static let title1: Font = makeFont(textStyle: .title1, size: 28, weight: .bold) + static let title2: Font = makeFont(textStyle: .title2, size: 22, weight: .bold) + static let title3: Font = makeFont(textStyle: .title3, size: 20, weight: .bold) + static let headline: Font = makeFont(textStyle: .headline, size: 17, weight: .medium) + static let subheadline: Font = makeFont(textStyle: .subheadline, size: 16) + static let body: Font = makeFont(textStyle: .body, size: 15) + static let callout: Font = makeFont(textStyle: .callout, size: 14) + static let footnote: Font = makeFont(textStyle: .footnote, size: 13) + static let boldFootnote: Font = makeFont(textStyle: .footnote, size: 13, weight: .semibold) + static let caption1: Font = makeFont(textStyle: .caption1, size: 12) + static let caption2: Font = makeFont(textStyle: .caption2, size: 11) + static let boldCaption2: Font = makeFont(textStyle: .caption2, size: 11, weight: .bold) +} + +private func makeFont( + textStyle: Font.TextStyle, + size: CGFloat, + weight: Font.Weight = .regular +) -> Font { +#if os(iOS) + let font = Font.systemFont(ofSize: size, weight: weight) + return UIFontMetrics(forTextStyle: textStyle).scaledFont(for: font) +#endif + +#if os(macOS) + Font.systemFont(ofSize: size, weight: weight) +#endif +} diff --git a/Shared/Tokens/Spacings.swift b/Shared/Tokens/Spacings.swift new file mode 100644 index 0000000..c22928d --- /dev/null +++ b/Shared/Tokens/Spacings.swift @@ -0,0 +1,28 @@ +/// +/// Spacings.swift +/// Bemol +/// +/// Copyright 2025 Faiçal Tchirou +/// +/// Bemol is free software: you can redistribute it and/or modify it under the terms of +/// the GNU General Public License as published by the Free Software Foundation, either version 3 +/// of the License, or (at your option) any later version. +/// +/// Bemol is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +/// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +/// See the GNU General Public License for more details. +/// +/// You should have received a copy of the GNU General Public License along with Foobar. +/// If not, see . +/// + +import Foundation + +extension CGFloat { + public static let spacingXxxs: CGFloat = 1 + public static let spacingXxs: CGFloat = 2 + public static let spacingXs: CGFloat = 8 + public static let spacingSm: CGFloat = 16 + public static let spacingMd: CGFloat = 24 + public static let spacingLg: CGFloat = 32 +} diff --git a/Shared/Views/Core/Action.swift b/Shared/Views/Core/Action.swift new file mode 100644 index 0000000..4e28f20 --- /dev/null +++ b/Shared/Views/Core/Action.swift @@ -0,0 +1,40 @@ +/// +/// Action.swift +/// Bemol +/// +/// Copyright 2026 Faiçal Tchirou +/// +/// Bemol is free software: you can redistribute it and/or modify it under the terms of +/// the GNU General Public License as published by the Free Software Foundation, either version 3 +/// of the License, or (at your option) any later version. +/// +/// Bemol is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +/// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +/// See the GNU General Public License for more details. +/// +/// You should have received a copy of the GNU General Public License along with Foobar. +/// If not, see . +/// + +#if os(macOS) +import AppKit + +@MainActor +public final class Action { + private let handler: (Action) -> Void + + init(handler: @escaping (Action) -> Void) { + self.handler = handler + } + + func perform() { + handler(self) + } +} +#endif + + +#if os(iOS) +import UIKit +typealias Action = UIAction +#endif diff --git a/Shared/Views/Core/BezierPath.swift b/Shared/Views/Core/BezierPath.swift new file mode 100644 index 0000000..05c2262 --- /dev/null +++ b/Shared/Views/Core/BezierPath.swift @@ -0,0 +1,52 @@ +/// +/// BezierPath.swift +/// Bemol +/// +/// Copyright 2026 Faiçal Tchirou +/// +/// Bemol is free software: you can redistribute it and/or modify it under the terms of +/// the GNU General Public License as published by the Free Software Foundation, either version 3 +/// of the License, or (at your option) any later version. +/// +/// Bemol is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +/// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +/// See the GNU General Public License for more details. +/// +/// You should have received a copy of the GNU General Public License along with Foobar. +/// If not, see . +/// + +#if os(macOS) +import AppKit +typealias BezierPath = NSBezierPath + +extension BezierPath { + convenience init( + arcCenter center: CGPoint, + radius: CGFloat, + startAngle: CGFloat, + endAngle: CGFloat, + clockwise: Bool + ) { + self.init() + let startAngle = Measurement(value: startAngle, unit: UnitAngle.radians) + .converted(to: .degrees).value + let endAngle = Measurement(value: endAngle, unit: UnitAngle.radians) + .converted(to: .degrees).value + + appendArc( + withCenter: center, + radius: radius, + startAngle: -startAngle, + endAngle: -endAngle, + clockwise: clockwise + ) + } +} +#endif + + +#if os(iOS) +import UIKit +typealias BezierPath = UIBezierPath +#endif diff --git a/Shared/Views/Core/Button.swift b/Shared/Views/Core/Button.swift new file mode 100644 index 0000000..98ece47 --- /dev/null +++ b/Shared/Views/Core/Button.swift @@ -0,0 +1,126 @@ +/// +/// Action.swift +/// Bemol +/// +/// Copyright 2026 Faiçal Tchirou +/// +/// Bemol is free software: you can redistribute it and/or modify it under the terms of +/// the GNU General Public License as published by the Free Software Foundation, either version 3 +/// of the License, or (at your option) any later version. +/// +/// Bemol is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +/// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +/// See the GNU General Public License for more details. +/// +/// You should have received a copy of the GNU General Public License along with Foobar. +/// If not, see . +/// + +enum ButtonType { + case plain +} + +enum ButtonSize { + case small +} + +#if os(macOS) +import AppKit + +@MainActor +final class Button: Control { + + // MARK: - Factories + + static func secondary(title: String) -> Button { + let button = NSButton(title: title, target: nil, action: nil) + button.setUp() + button.tintProminence = .secondary + button.setContentHuggingPriority(.required, for: .horizontal) + button.setContentCompressionResistancePriority(.required, for: .horizontal) + button.sizeToFit() + + return .init(button) + } + + static func primary(title: String) -> Button { + let button = NSButton(title: title, target: nil, action: nil) + button.setUp() + button.tintProminence = .primary + button.setContentHuggingPriority(.required, for: .horizontal) + button.setContentCompressionResistancePriority(.required, for: .horizontal) + button.sizeToFit() + + return .init(button) + } + + // MARK: - Private API + + private let button: NSButton + private var primaryAction: Action? = nil + + private init(_ button: NSButton) { + self.button = button + super.init(frame: .zero) + + addSubview(button) + LayoutConstraint.activate([ + button.leadingAnchor.constraint(equalTo: leadingAnchor), + button.topAnchor.constraint(equalTo: topAnchor), + button.bottomAnchor.constraint(equalTo: bottomAnchor), + button.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Actions + + override func addAction(_ action: Action, for controlEvents: Control.Event) { + self.primaryAction = action + button.action = #selector(performAction) + button.target = self + } + + @objc private func performAction() { + primaryAction?.perform() + } +} +#endif + + +#if os(iOS) +import UIKit +typealias Button = UIButton + +extension Button { + static func secondary(title: String) -> Button { + let button = UIButton(configuration: .plain()) + button.setUp() + button.configuration?.title = title + button.configuration?.baseForegroundColor = .systemOrange + button.configuration?.titleAlignment = .center + button.configuration?.buttonSize = .small + button.setContentHuggingPriority(.required, for: .horizontal) + button.setContentCompressionResistancePriority(.required, for: .horizontal) + + return button + } + + static func primary(title: String) -> Button { + let button = UIButton(configuration: .filled()) + button.setUp() + button.configuration?.title = String(localized: "done") + button.configuration?.baseBackgroundColor = .systemOrange + button.configuration?.baseForegroundColor = .buttonForeground + button.configuration?.titleAlignment = .center + button.configuration?.buttonSize = .small + button.setContentHuggingPriority(.required, for: .horizontal) + button.setContentCompressionResistancePriority(.required, for: .horizontal) + + return button + } +} +#endif diff --git a/Shared/Views/Core/CGAffineTransform.swift b/Shared/Views/Core/CGAffineTransform.swift new file mode 100644 index 0000000..55c14c1 --- /dev/null +++ b/Shared/Views/Core/CGAffineTransform.swift @@ -0,0 +1,18 @@ +import CoreGraphics +import Foundation + +#if os(macOS) +import AppKit +#endif + +extension CGAffineTransform { + static func rotation(angle: CGFloat) -> CGAffineTransform { +#if os(iOS) + CGAffineTransform(rotationAngle: angle) +#endif + +#if os(macOS) + CGAffineTransform(rotationAngle: -angle) +#endif + } +} diff --git a/Shared/Views/Core/Control.swift b/Shared/Views/Core/Control.swift new file mode 100644 index 0000000..3098d46 --- /dev/null +++ b/Shared/Views/Core/Control.swift @@ -0,0 +1,87 @@ +/// +/// Control.swift +/// Bemol +/// +/// Copyright 2026 Faiçal Tchirou +/// +/// Bemol is free software: you can redistribute it and/or modify it under the terms of +/// the GNU General Public License as published by the Free Software Foundation, either version 3 +/// of the License, or (at your option) any later version. +/// +/// Bemol is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +/// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +/// See the GNU General Public License for more details. +/// +/// You should have received a copy of the GNU General Public License along with Foobar. +/// If not, see . +/// + +#if os(macOS) +import AppKit + +@MainActor +open class Control: NSControl { + + // MARK: - Event + + public struct Event: Hashable { + static var touchUpInside: Control.Event = .init(rawValue: 1) + static var touchDown: Control.Event = .init(rawValue: 2) + + private let rawValue: UInt + + init(rawValue: UInt) { + self.rawValue = rawValue + } + } + + // MARK: - + + open var isSelected: Bool = false { + didSet { + isHighlighted = isSelected + } + } + + // MARK: - Actions + + private var actions: [Control.Event: Action] = [:] + + open func addAction( + _ action: Action, + for controlEvents: Control.Event + ) { + actions[controlEvents] = action + } + + open func beginTracking(_ touch: Touch, with event: UIEvent?) -> Bool { + return true + } + + open func endTracking(_ touch: Touch?, with event: UIEvent?) { + } + + func sendActions(for event: Control.Event) { + actions[event]?.perform() + } + + override open func mouseDown(with event: NSEvent) { + if isEnabled && beginTracking(Touch(), with: event) { + actions[.touchDown]?.perform() + } + } + + override open func mouseUp(with event: NSEvent) { + guard isEnabled else { return } + + endTracking(nil, with: event) + actions[.touchUpInside]?.perform() + } +} +#endif + + +#if os(iOS) +import UIKit +typealias Control = UIControl +#endif diff --git a/Shared/Views/Core/Event.swift b/Shared/Views/Core/Event.swift new file mode 100644 index 0000000..a97c8ae --- /dev/null +++ b/Shared/Views/Core/Event.swift @@ -0,0 +1,22 @@ +/// +/// Event.swift +/// Bemol +/// +/// Copyright 2026 Faiçal Tchirou +/// +/// Bemol is free software: you can redistribute it and/or modify it under the terms of +/// the GNU General Public License as published by the Free Software Foundation, either version 3 +/// of the License, or (at your option) any later version. +/// +/// Bemol is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +/// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +/// See the GNU General Public License for more details. +/// +/// You should have received a copy of the GNU General Public License along with Foobar. +/// If not, see . +/// + +#if os(macOS) +import AppKit +public typealias UIEvent = NSEvent +#endif diff --git a/Shared/Views/Core/Label.swift b/Shared/Views/Core/Label.swift new file mode 100644 index 0000000..b629749 --- /dev/null +++ b/Shared/Views/Core/Label.swift @@ -0,0 +1,77 @@ +/// +/// Label.swift +/// Bemol +/// +/// Copyright 2026 Faiçal Tchirou +/// +/// Bemol is free software: you can redistribute it and/or modify it under the terms of +/// the GNU General Public License as published by the Free Software Foundation, either version 3 +/// of the License, or (at your option) any later version. +/// +/// Bemol is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +/// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +/// See the GNU General Public License for more details. +/// +/// You should have received a copy of the GNU General Public License along with Foobar. +/// If not, see . +/// + +#if os(macOS) +import AppKit + +final class Label: NSTextField { + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + initialize() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + initialize() + } + + var numberOfLines: Int { + get { maximumNumberOfLines } + set { maximumNumberOfLines = newValue } + } + + var text: String? { + get { stringValue } + set { stringValue = newValue ?? "" } + } + + var textAlignment: NSTextAlignment { + get { .center } + set { } + } + + var attributedText: NSAttributedString? { + get { attributedStringValue } + set { if let newValue { attributedStringValue = newValue } } + } + + var adjustsFontForContentSizeCategory = false + var adjustsFontSizeToFitWidth = false + var maximumContentSizeCategory = false + + private func initialize() { + isEditable = false + isSelectable = false + isBezeled = false + backgroundColor = .clear + } +} +#endif + + +#if os(iOS) +import UIKit +typealias Label = UILabel + +extension Label { + var fittingSize: CGSize { + return systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + } +} +#endif + diff --git a/Shared/Views/Core/LayoutConstraint.swift b/Shared/Views/Core/LayoutConstraint.swift new file mode 100644 index 0000000..c9289c1 --- /dev/null +++ b/Shared/Views/Core/LayoutConstraint.swift @@ -0,0 +1,38 @@ +/// +/// LayoutConstraint.swift +/// Bemol +/// +/// Copyright 2026 Faiçal Tchirou +/// +/// Bemol is free software: you can redistribute it and/or modify it under the terms of +/// the GNU General Public License as published by the Free Software Foundation, either version 3 +/// of the License, or (at your option) any later version. +/// +/// Bemol is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +/// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +/// See the GNU General Public License for more details. +/// +/// You should have received a copy of the GNU General Public License along with Foobar. +/// If not, see . +/// + +#if os(macOS) +import AppKit + +typealias LayoutConstraint = NSLayoutConstraint + +extension LayoutConstraint.Priority { + static let none = LayoutConstraint.Priority(1) +} +#endif + + +#if os(iOS) +import UIKit + +typealias LayoutConstraint = NSLayoutConstraint + +extension UILayoutPriority { + static let none = UILayoutPriority(1) +} +#endif diff --git a/Shared/Views/Core/ScrollView.swift b/Shared/Views/Core/ScrollView.swift new file mode 100644 index 0000000..a12adb6 --- /dev/null +++ b/Shared/Views/Core/ScrollView.swift @@ -0,0 +1,96 @@ +/// +/// ScrollView.swift +/// Bemol +/// +/// Copyright 2026 Faiçal Tchirou +/// +/// Bemol is free software: you can redistribute it and/or modify it under the terms of +/// the GNU General Public License as published by the Free Software Foundation, either version 3 +/// of the License, or (at your option) any later version. +/// +/// Bemol is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +/// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +/// See the GNU General Public License for more details. +/// +/// You should have received a copy of the GNU General Public License along with Foobar. +/// If not, see . +/// + + +#if os(macOS) +import AppKit + +public typealias ScrollView = NSScrollView + +extension ScrollView { + var showsHorizontalScrollIndicator: Bool { + get { hasHorizontalScroller } + set { hasHorizontalScroller = newValue } + } + + var showsVerticalScrollIndicator: Bool { + get { hasVerticalScroller } + set { hasVerticalScroller = newValue } + } + + var bounces: Bool { + get { false } + set { } + } + + var isScrollEnabled: Bool { + get { true } + set {} + } + + func addContentView(_ contentView: NSView) { + let clipView = NSClipView() + clipView.translatesAutoresizingMaskIntoConstraints = false + clipView.documentView = contentView + + self.contentView = clipView + NSLayoutConstraint.activate([ + contentView.leadingAnchor.constraint(equalTo: clipView.leadingAnchor), + contentView.topAnchor.constraint(equalTo: clipView.topAnchor), + contentView.bottomAnchor.constraint(equalTo: clipView.bottomAnchor), + ]) + } + + func scrollTo(_ point: CGPoint, animated: Bool) { + guard animated else { + scroll(contentView, to: point) + return + } + + // https://stackoverflow.com/a/49672274 + NSAnimationContext.beginGrouping() + NSAnimationContext.current.duration = 0.2 + contentView.animator().setBoundsOrigin(point) + reflectScrolledClipView(contentView) + NSAnimationContext.endGrouping() + } +} +#endif + +#if os(iOS) +import UIKit + +public typealias ScrollView = UIScrollView + +extension ScrollView { + func addContentView(_ contentView: UIView) { + addSubview(contentView) + + NSLayoutConstraint.activate([ + contentView.leadingAnchor.constraint(equalTo: contentLayoutGuide.leadingAnchor), + contentView.topAnchor.constraint(equalTo: contentLayoutGuide.topAnchor), + contentView.bottomAnchor.constraint(equalTo: contentLayoutGuide.bottomAnchor), + contentLayoutGuide.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + ]) + } + + func scrollTo(_ point: CGPoint, animated: Bool) { + setContentOffset(point, animated: animated) + } +} +#endif diff --git a/Shared/Views/Core/Touch.swift b/Shared/Views/Core/Touch.swift new file mode 100644 index 0000000..8711e89 --- /dev/null +++ b/Shared/Views/Core/Touch.swift @@ -0,0 +1,28 @@ +/// +/// Touch.swift +/// Bemol +/// +/// Copyright 2026 Faiçal Tchirou +/// +/// Bemol is free software: you can redistribute it and/or modify it under the terms of +/// the GNU General Public License as published by the Free Software Foundation, either version 3 +/// of the License, or (at your option) any later version. +/// +/// Bemol is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +/// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +/// See the GNU General Public License for more details. +/// +/// You should have received a copy of the GNU General Public License along with Foobar. +/// If not, see . +/// + +#if os(macOS) +import AppKit +public typealias Touch = NSTouch +#endif + + +#if os(iOS) +import UIKit +public typealias Touch = UITouch +#endif diff --git a/Shared/Views/Core/View.swift b/Shared/Views/Core/View.swift new file mode 100644 index 0000000..8adb973 --- /dev/null +++ b/Shared/Views/Core/View.swift @@ -0,0 +1,208 @@ +/// +/// View.swift +/// Bemol +/// +/// Copyright 2026 Faiçal Tchirou +/// +/// Bemol is free software: you can redistribute it and/or modify it under the terms of +/// the GNU General Public License as published by the Free Software Foundation, either version 3 +/// of the License, or (at your option) any later version. +/// +/// Bemol is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +/// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +/// See the GNU General Public License for more details. +/// +/// You should have received a copy of the GNU General Public License along with Foobar. +/// If not, see . +/// + + +#if os(macOS) +import AppKit + +typealias View = NSView + +extension View { + var alpha: CGFloat { + get { alphaValue } + set { alphaValue = newValue } + } + + var backgroundStyle: Color? { + get { layer?.backgroundColor.flatMap { Color(cgColor: $0) } } + set { layer?.backgroundColor = newValue?.cgColor } + } + + var cornerRadius: CGFloat { + get { layer?.cornerRadius ?? 0 } + set { layer?.cornerRadius = newValue } + } + + var maskedCorners: CACornerMask { + get { layer?.maskedCorners ?? [] } + set { + var newMaskedCorners: CACornerMask = [] + + if newValue.contains(.layerMinXMaxYCorner) { + newMaskedCorners.insert(.layerMinXMinYCorner) + } + + if newValue.contains(.layerMaxXMaxYCorner) { + newMaskedCorners.insert(.layerMaxXMinYCorner) + } + + layer?.maskedCorners = newMaskedCorners + } + } + + var transform: CGAffineTransform { + get { (layer?.transform).flatMap { CATransform3DGetAffineTransform($0) } ?? .identity } + set { layer?.transform = CATransform3DMakeAffineTransform(newValue) } + } + + var shadowColor: CGColor { + get { layer?.shadowColor ?? .clear } + set { layer?.shadowColor = newValue } + } + + var shadowRadius: CGFloat { + get { layer?.shadowRadius ?? 0 } + set { layer?.shadowRadius = newValue } + } + + var shadowOffset: CGSize { + get { layer?.shadowOffset ?? .zero } + set { layer?.shadowOffset = newValue } + } + + var shadowOpacity: Float { + get { layer?.shadowOpacity ?? 0 } + set { layer?.shadowOpacity = newValue } + } + + var masksToBounds: Bool { + get { layer?.masksToBounds ?? false } + set { layer?.masksToBounds = newValue } + } + + var isUserInteractionEnabled: Bool { + get { + (objc_getAssociatedObject(self, &AssociatedKeys.isUserInteractionEnabled) as? Bool) ?? false + } + + set { + objc_setAssociatedObject( + self, + &AssociatedKeys.isUserInteractionEnabled, + newValue, + .OBJC_ASSOCIATION_COPY + ) + } + } + + func layoutIfNeeded() { + layoutSubtreeIfNeeded() + } + + func setUp() { + wantsLayer = true + translatesAutoresizingMaskIntoConstraints = false + } + + func rotate(by angleRadians: CGFloat) { + let rotation = CGAffineTransform(rotationAngle: -angleRadians) + let translation = CGAffineTransform( + translationX: (bounds.height / 2) + (bounds.width / 2), + y: 0 + ) + + // We're rotating about (0, 0) on macOS, so we need to translate + // after the rotation to obtain the same effect as on iOS. + let transform = rotation.concatenating(translation) + layer?.transform = CATransform3DMakeAffineTransform(transform) + } + + func bringSubviewToFront(_ view: View) { + } + + func addSublayer(_ layer: CALayer) { + self.layer?.addSublayer(layer) + } + + func setNeedsLayout() { + needsLayout = true + } +} + +private struct AssociatedKeys { + static var isUserInteractionEnabled = 1 +} +#endif + + +#if os(iOS) +import UIKit + +typealias View = UIView + +extension View { + var backgroundStyle: UIColor? { + get { backgroundColor } + set { backgroundColor = newValue } + } + + var cornerRadius: CGFloat { + get { layer.cornerRadius } + set { layer.cornerRadius = newValue } + } + + var maskedCorners: CACornerMask { + get { layer.maskedCorners } + set { layer.maskedCorners = newValue } + } + + var shadowColor: CGColor { + get { layer.shadowColor ?? UIColor.clear.cgColor } + set { layer.shadowColor = newValue } + } + + var shadowRadius: CGFloat { + get { layer.shadowRadius } + set { layer.shadowRadius = newValue } + } + + var shadowOffset: CGSize { + get { layer.shadowOffset } + set { layer.shadowOffset = newValue } + } + + var shadowOpacity: Float { + get { layer.shadowOpacity } + set { layer.shadowOpacity = newValue } + } + + var masksToBounds: Bool { + get { layer.masksToBounds } + set { layer.masksToBounds = newValue } + } + + func setUp() { + translatesAutoresizingMaskIntoConstraints = false + } + + func addSublayer(_ layer: CALayer) { + self.layer.addSublayer(layer) + } + + func rotate(by angleRadians: CGFloat) { + transform = CGAffineTransform(rotationAngle: angleRadians) + } + + func setAccessibilityLabel(_ accessibilityLabel: String?) { + self.accessibilityLabel = accessibilityLabel + } +} + +extension View: UserInteractionToggleable {} + +#endif diff --git a/Bemol/Views/Keyboard/BlackKey.swift b/Shared/Views/Keyboard/BlackKey.swift similarity index 70% rename from Bemol/Views/Keyboard/BlackKey.swift rename to Shared/Views/Keyboard/BlackKey.swift index 49a34ab..20491ce 100644 --- a/Bemol/Views/Keyboard/BlackKey.swift +++ b/Shared/Views/Keyboard/BlackKey.swift @@ -17,73 +17,79 @@ /// import Foundation + +#if os(macOS) +import AppKit +#endif + +#if os(iOS) import UIKit +#endif -final class BlackKey: UIControl { +final class BlackKey: Control { // MARK: - Subviews - private lazy var label: UILabel = { - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false + private lazy var label: Label = { + let label = Label() + label.setUp() label.font = .boldCaption2 label.textColor = .white label.textAlignment = .left label.adjustsFontForContentSizeCategory = true label.adjustsFontSizeToFitWidth = true label.isUserInteractionEnabled = false - label.transform = CGAffineTransform(rotationAngle: .pi * 3 / 2) return label }() - private lazy var bevel: UIView = { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - view.backgroundColor = .keyBed - view.layer.cornerRadius = .cornerRadiusLg - view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] + private lazy var bevel: View = { + let view = View() + view.setUp() + view.backgroundStyle = .keyBed + view.cornerRadius = .cornerRadiusLg + view.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] view.isUserInteractionEnabled = false return view }() - private lazy var key: UIView = { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - view.backgroundColor = .dark - view.layer.cornerRadius = .cornerRadiusLg - view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] + private lazy var key: View = { + let view = View() + view.setUp() + view.backgroundStyle = .dark + view.cornerRadius = .cornerRadiusLg + view.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] view.isUserInteractionEnabled = false - + return view }() - private lazy var interactionBlocker: UIView = { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - view.backgroundColor = .clear + private lazy var interactionBlocker: View = { + let view = View() + view.setUp() + view.backgroundStyle = .clear view.isUserInteractionEnabled = true return view }() - private lazy var keyOverlay: UIView = { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - view.backgroundColor = .clear - view.layer.cornerRadius = .cornerRadiusLg - view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] + private lazy var keyOverlay: View = { + let view = View() + view.setUp() + view.backgroundStyle = .clear + view.cornerRadius = .cornerRadiusLg + view.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] view.isUserInteractionEnabled = false return view }() - private lazy var bevelOverlay: UIView = { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - view.backgroundColor = .clear - view.layer.cornerRadius = .cornerRadiusLg - view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] + private lazy var bevelOverlay: View = { + let view = View() + view.setUp() + view.backgroundStyle = .clear + view.cornerRadius = .cornerRadiusLg + view.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] view.isUserInteractionEnabled = false return view @@ -105,11 +111,10 @@ final class BlackKey: UIControl { var text: String? { didSet { label.text = text - accessibilityLabel = text } } - var tint: UIColor? { + var tint: Color? { didSet { guard let color = tint else { removeOverlay() @@ -136,6 +141,7 @@ final class BlackKey: UIControl { override public init(frame: CGRect) { super.init(frame: frame) + setUp() setUpViewHierarchy() setUpAppearance() } @@ -146,19 +152,19 @@ final class BlackKey: UIControl { // MARK: - API - func setTint(_ color: UIColor, percent: Double) { - NSLayoutConstraint.deactivate([keyOverlayHeightConstraint]) + func setTint(_ color: Color, percent: Double) { + LayoutConstraint.deactivate([keyOverlayHeightConstraint]) keyOverlayHeightConstraint = keyOverlay.heightAnchor.constraint( equalTo: key.heightAnchor, multiplier: percent ) - NSLayoutConstraint.activate([keyOverlayHeightConstraint]) + LayoutConstraint.activate([keyOverlayHeightConstraint]) - keyOverlay.backgroundColor = color - bevelOverlay.backgroundColor = color.darker(0.4) + keyOverlay.backgroundStyle = color + bevelOverlay.backgroundStyle = color.darker(0.4) keyOverlay.isHidden = false bevelOverlay.isHidden = percent <= 0 - + if percent >= 0.1 { label.textColor = color.bestContrastingColor() } @@ -172,33 +178,66 @@ final class BlackKey: UIControl { // MARK: - Tracking - override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + override func beginTracking(_ touch: Touch, with event: UIEvent?) -> Bool { bottomAnchorConstraint.constant = -.spacingSm * bottomAnchorConstraintConstantMultiplier() return true } - override func endTracking(_ touch: UITouch?, with event: UIEvent?) { + override func endTracking(_ touch: Touch?, with event: UIEvent?) { bottomAnchorConstraint.constant = -.spacingMd * bottomAnchorConstraintConstantMultiplier() } +#if os(iOS) + override func layoutSubviews() { + super.layoutSubviews() + performLayout() + } +#endif + +#if os(macOS) + override func layout() { + super.layout() + performLayout() + } +#endif + // MARK: - Private + private func performLayout() { + label.rotate(by: .pi * 3 / 2) + } + private func setUpAppearance() { + setUpAccessibility() + } + + private func updateAppearance() { + if !isEnabled { + installInteractionBlocker() + key.backgroundStyle = .dark.lighter(0.1) + bevel.backgroundStyle = .keyBed.lighter(0.1) + } else { + removeInteractionBlocker() + key.backgroundStyle = .dark + bevel.backgroundStyle = .keyBed.darker(0.3) + } + + updateAccessibility() + } + + private func setUpAccessibility() { +#if os(iOS) isAccessibilityElement = true accessibilityTraits = super.accessibilityTraits.union([.button]) shouldGroupAccessibilityChildren = true +#endif } - private func updateAppearance() { + private func updateAccessibility() { +#if os(iOS) if !isEnabled { - installInteractionBlocker() - key.backgroundColor = .clear - bevel.backgroundColor = .disabledBackKey accessibilityTraits = [.button, .notEnabled] } else { - removeInteractionBlocker() - key.backgroundColor = .dark - bevel.backgroundColor = .keyBed.darker(0.3) accessibilityTraits = [.button] } @@ -207,12 +246,13 @@ final class BlackKey: UIControl { } else { accessibilityTraits.remove(.selected) } +#endif } private func installInteractionBlocker() { superview?.addSubview(interactionBlocker) - NSLayoutConstraint.activate([ + LayoutConstraint.activate([ interactionBlocker.leadingAnchor.constraint(equalTo: leadingAnchor), interactionBlocker.topAnchor.constraint(equalTo: topAnchor), interactionBlocker.trailingAnchor.constraint(equalTo: trailingAnchor), @@ -234,8 +274,8 @@ final class BlackKey: UIControl { addSubview(bevelOverlay) addSubview(keyOverlay) addSubview(label) - - NSLayoutConstraint.activate([ + + LayoutConstraint.activate([ bevel.leadingAnchor.constraint(equalTo: leadingAnchor), bevel.topAnchor.constraint(equalTo: topAnchor), bevel.trailingAnchor.constraint(equalTo: trailingAnchor), @@ -268,19 +308,29 @@ final class BlackKey: UIControl { } private func keyLeadingTrailingConstraintsConstant() -> CGFloat { +#if os(macOS) + return .spacingXs +#endif + +#if os(iOS) switch ( traitCollection.verticalSizeClass, traitCollection.horizontalSizeClass ) { case (.regular, .regular): - .spacingXs + .spacingXxs + .spacingXxxs + .spacingXs + .spacingXxs + .spacingXxxs default: - .spacingXs + .spacingXs } - +#endif } private func bottomAnchorConstraintConstantMultiplier() -> CGFloat { +#if os(macOS) + return 1.0 +#endif + +#if os(iOS) switch ( traitCollection.verticalSizeClass, traitCollection.horizontalSizeClass @@ -290,5 +340,6 @@ final class BlackKey: UIControl { default: 1.0 } +#endif } } diff --git a/Bemol/Views/Keyboard/KeyboardView.swift b/Shared/Views/Keyboard/KeyboardView.swift similarity index 83% rename from Bemol/Views/Keyboard/KeyboardView.swift rename to Shared/Views/Keyboard/KeyboardView.swift index 563a363..5761dc9 100644 --- a/Bemol/Views/Keyboard/KeyboardView.swift +++ b/Shared/Views/Keyboard/KeyboardView.swift @@ -17,7 +17,14 @@ /// import Foundation + +#if os(macOS) +import AppKit +#endif + +#if os(iOS) import UIKit +#endif struct KeyboardViewDelegate { let didPressNote: (Note) -> Void @@ -36,30 +43,40 @@ struct KeyboardViewDelegate { } @MainActor -final class KeyboardView: UIView { +final class KeyboardView: View { // MARK: - Subviews - private lazy var scrollView: UIScrollView = { - let scrollView = UIScrollView() - scrollView.translatesAutoresizingMaskIntoConstraints = false - scrollView.panGestureRecognizer.cancelsTouchesInView = false + private lazy var scrollView: ScrollView = { + let scrollView = ScrollView() + scrollView.setUp() scrollView.showsHorizontalScrollIndicator = false scrollView.showsVerticalScrollIndicator = false scrollView.isScrollEnabled = true scrollView.bounces = true +#if os(iOS) + scrollView.panGestureRecognizer.cancelsTouchesInView = false +#endif + return scrollView }() - private lazy var contentView: UIView = { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false + private lazy var contentView: View = { + let view = View() + view.setUp() + + return view + }() + + private lazy var interactionBlocker: View = { + let view = View() + view.setUp() return view }() private var octaveViews: [OctaveView] = [] - private var octaves: [[UIView]] = [] + private var octaves: [[View]] = [] // MARK: - API @@ -69,7 +86,7 @@ final class KeyboardView: UIView { get { scrollView.isScrollEnabled } set { scrollView.isScrollEnabled = newValue } } - + // MARK: - Properties private let range: ClosedRange @@ -77,10 +94,11 @@ final class KeyboardView: UIView { init(range: ClosedRange) { self.range = range super.init(frame: .zero) + setUp() setUpViewHierarchy() setUpOctaveViews() } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -89,7 +107,7 @@ final class KeyboardView: UIView { func scrollTo(note: Note, animated: Bool = true) { if let view = octaveViews[Int(note.octave - range.lowerBound)].view(for: note.name) { - scrollView.setContentOffset(CGPoint(x: view.frame.minX, y: 0), animated: animated) + scrollView.scrollTo(CGPoint(x: view.frame.minX, y: 0), animated: animated) } } @@ -115,7 +133,7 @@ final class KeyboardView: UIView { } } - func setTints(_ colors: [UIColor?], for notes: [Note]) { + func setTints(_ colors: [Color?], for notes: [Note]) { guard colors.count == notes.count else { return } for (i, note) in notes.enumerated() { @@ -123,13 +141,13 @@ final class KeyboardView: UIView { } } - func setTint(_ color: UIColor?, for notes: [Note]) { + func setTint(_ color: Color?, for notes: [Note]) { for note in notes { setTint(color, for: note) } } - func setTintForAllNotes(_ color: UIColor?) { + func setTintForAllNotes(_ color: Color?) { for i in 0.. NSView? { + guard isUserInteractionEnabled else { return nil } + + return super.hitTest(point) + } +#endif + // MARK: - Private private func setUpViewHierarchy() { addSubview(scrollView) - scrollView.addSubview(contentView) + scrollView.addContentView(contentView) + scrollView.backgroundColor = .keyBed - NSLayoutConstraint.activate([ - contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), - contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), - contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), - scrollView.contentLayoutGuide.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + LayoutConstraint.activate([ contentView.heightAnchor.constraint(equalTo: heightAnchor), - scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), scrollView.topAnchor.constraint(equalTo: topAnchor), scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), @@ -192,7 +216,7 @@ final class KeyboardView: UIView { private func setUpOctaveViews() { for octave in range { let view = OctaveView(octave: octave) - view.translatesAutoresizingMaskIntoConstraints = false + view.setUp() view.delegate = OctaveViewDelegate( didPressNote: { [weak self] note, octave in self?.didPressNote(note, octave: octave) }, didReleaseNote: { [weak self] note, octave in self?.didReleaseNote(note, octave: octave) }, diff --git a/Bemol/Views/Keyboard/OctaveView.swift b/Shared/Views/Keyboard/OctaveView.swift similarity index 83% rename from Bemol/Views/Keyboard/OctaveView.swift rename to Shared/Views/Keyboard/OctaveView.swift index 8b60dc4..b4e0afc 100644 --- a/Bemol/Views/Keyboard/OctaveView.swift +++ b/Shared/Views/Keyboard/OctaveView.swift @@ -17,7 +17,14 @@ /// import Foundation + +#if os(macOS) +import AppKit +#endif + +#if os(iOS) import UIKit +#endif struct OctaveViewDelegate { let didPressNote: (NoteName, Octave) -> Void @@ -26,7 +33,7 @@ struct OctaveViewDelegate { } @MainActor -final class OctaveView: UIView { +final class OctaveView: View { // MARK: - Properties private let octave: Octave @@ -61,10 +68,17 @@ final class OctaveView: UIView { init(octave: Octave) { self.octave = octave super.init(frame: .zero) + setUp() setUpViewHierarchy() setUpAppearance() } + #if os(macOS) + override var isFlipped: Bool { + true + } + #endif + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -81,7 +95,7 @@ final class OctaveView: UIView { accidentalNotes[note]?.isSelected = selected } - func setTint(_ tint: UIColor?, for note: NoteName) { + func setTint(_ tint: Color?, for note: NoteName) { naturalNotes[note]?.tint = tint accidentalNotes[note]?.tint = tint } @@ -91,18 +105,30 @@ final class OctaveView: UIView { accidentalNotes[note]?.text = label } - func setTint(_ color: UIColor, percent: Double, for note: NoteName) { + func setTint(_ color: Color, percent: Double, for note: NoteName) { naturalNotes[note]?.setTint(color, percent: percent) accidentalNotes[note]?.setTint(color, percent: percent) } - func view(for note: NoteName) -> UIView? { + func view(for note: NoteName) -> View? { naturalNotes[note] ?? accidentalNotes[note] } +#if os(iOS) override func layoutSubviews() { super.layoutSubviews() + performLayout() + } +#endif +#if os(macOS) + override func layout() { + super.layout() + performLayout() + } +#endif + + private func performLayout() { let whiteKeyWidth = self.bounds.width / CGFloat(naturalNotes.count) let blackKeyWidth = whiteKeyWidth / 2 let blackKeyHeight = bounds.height / 1.5 @@ -111,6 +137,7 @@ final class OctaveView: UIView { for (i, note) in notes.enumerated() { let key = naturalNotes[note]! + key.translatesAutoresizingMaskIntoConstraints = true key.frame = CGRect( x: CGFloat(i) * whiteKeyWidth, y: 0, @@ -119,37 +146,41 @@ final class OctaveView: UIView { ) } + for note in accidentalNotes.values { + note.translatesAutoresizingMaskIntoConstraints = true + } + accidentalNotes[.dFlat]?.frame = CGRect( x: naturalNotes[.c]!.frame.maxX - (whiteKeyWidth / 3), - y: 0, + y: -1, width: blackKeyWidth, height: blackKeyHeight ) accidentalNotes[.eFlat]?.frame = CGRect( x: naturalNotes[.d]!.frame.maxX - (whiteKeyWidth / 5), - y: 0, + y: -1, width: blackKeyWidth, height: blackKeyHeight ) accidentalNotes[.gFlat]?.frame = CGRect( x: naturalNotes[.f]!.frame.maxX - (whiteKeyWidth / 3), - y: 0, + y: -1, width: blackKeyWidth, height: blackKeyHeight ) accidentalNotes[.aFlat]?.frame = CGRect( x: naturalNotes[.g]!.frame.maxX - (blackKeyWidth / 2), - y: 0, + y: -1, width: blackKeyWidth, height: blackKeyHeight ) accidentalNotes[.bFlat]?.frame = CGRect( x: naturalNotes[.a]!.frame.maxX - (whiteKeyWidth / 5), - y: 0, + y: -1, width: blackKeyWidth, height: blackKeyHeight ) @@ -167,9 +198,9 @@ final class OctaveView: UIView { } } - private func setUpActions(for note: NoteName, key: UIControl) { + private func setUpActions(for note: NoteName, key: Control) { key.addAction( - UIAction { [weak self] _ in + Action { [weak self] _ in guard let self else { return } self.delegate?.didPressNote(note, self.octave) }, @@ -177,7 +208,7 @@ final class OctaveView: UIView { ) key.addAction( - UIAction { [weak self] _ in + Action { [weak self] _ in guard let self else { return } self.delegate?.didReleaseNote(note, self.octave) }, @@ -185,7 +216,7 @@ final class OctaveView: UIView { ) key.addAction( - UIAction { [weak self] _ in + Action { [weak self] _ in guard let self else { return } self.delegate?.didDismissTip() }, @@ -212,7 +243,7 @@ extension OctaveView: TipHandler { } } -private extension CGRect { +extension CGRect { // Minimum rect that will satisfy the constraints in a key. - static let minKeyFrame = CGRect(x: 0, y: 0, width: 48, height: 48) + fileprivate static let minKeyFrame = CGRect(x: 0, y: 0, width: 48, height: 48) } diff --git a/Bemol/Views/Keyboard/WhiteKey.swift b/Shared/Views/Keyboard/WhiteKey.swift similarity index 75% rename from Bemol/Views/Keyboard/WhiteKey.swift rename to Shared/Views/Keyboard/WhiteKey.swift index 228237b..af9a648 100644 --- a/Bemol/Views/Keyboard/WhiteKey.swift +++ b/Shared/Views/Keyboard/WhiteKey.swift @@ -17,14 +17,21 @@ /// import Foundation + +#if os(macOS) +import AppKit +#endif + +#if os(iOS) import UIKit +#endif -final class WhiteKey: UIControl { +final class WhiteKey: Control { // MARK: - Subviews - private lazy var label: UILabel = { - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false + private lazy var label: Label = { + let label = Label() + label.setUp() label.font = .headline label.textColor = .lightGray label.textAlignment = .center @@ -36,34 +43,34 @@ final class WhiteKey: UIControl { return label }() - private lazy var backgroundView: UIView = { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - view.backgroundColor = .keyBed - view.layer.cornerRadius = .cornerRadiusLg - view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] + private lazy var backgroundView: View = { + let view = View() + view.setUp() + view.backgroundStyle = .keyBed + view.cornerRadius = .cornerRadiusLg + view.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] view.isUserInteractionEnabled = false return view }() - private lazy var key: UIView = { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - view.backgroundColor = .white - view.layer.cornerRadius = .cornerRadiusLg - view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] + private lazy var key: View = { + let view = View() + view.setUp() + view.backgroundStyle = .white + view.cornerRadius = .cornerRadiusLg + view.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] view.isUserInteractionEnabled = false return view }() - private lazy var overlay: UIView = { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - view.backgroundColor = .clear - view.layer.cornerRadius = .cornerRadiusLg - view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] + private lazy var overlay: View = { + let view = View() + view.setUp() + view.backgroundStyle = .clear + view.cornerRadius = .cornerRadiusLg + view.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] view.isUserInteractionEnabled = false return view @@ -88,6 +95,7 @@ final class WhiteKey: UIControl { override public init(frame: CGRect) { super.init(frame: frame) + setUp() setUpViewHierarchy() setUpAppearance() } @@ -101,11 +109,11 @@ final class WhiteKey: UIControl { var text: String? { didSet { label.text = text - accessibilityLabel = text + setAccessibilityLabel(text) } } - var tint: UIColor? { + var tint: Color? { didSet { guard let color = tint else { removeOverlay() @@ -130,28 +138,28 @@ final class WhiteKey: UIControl { // MARK: - Tracking - override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + override func beginTracking(_ touch: Touch, with event: UIEvent?) -> Bool { leadingAnchorConstraint.constant = .spacingXxs trailingAnchorConstraint.constant = -.spacingXxs return true } - override func endTracking(_ touch: UITouch?, with event: UIEvent?) { + override func endTracking(_ touch: Touch?, with event: UIEvent?) { leadingAnchorConstraint.constant = .spacingXxxs trailingAnchorConstraint.constant = -.spacingXxxs } // MARK: - API - func setTint(_ color: UIColor, percent: Double) { + func setTint(_ color: Color, percent: Double) { NSLayoutConstraint.deactivate([overlayheightAnchorConstraint]) overlayheightAnchorConstraint = overlay.heightAnchor.constraint( equalTo: key.heightAnchor, multiplier: percent ) NSLayoutConstraint.activate([overlayheightAnchorConstraint]) - - overlay.backgroundColor = color + + overlay.backgroundStyle = color overlay.isHidden = false if percent >= 0.1 { @@ -167,13 +175,24 @@ final class WhiteKey: UIControl { // MARK: - Private private func setUpAppearance() { + setUpAccessibility() + } + + private func updateAppearance() { + key.backgroundStyle = isEnabled ? .white : .white.withAlphaComponent(0.2) + updateAccessibility() + } + + private func setUpAccessibility() { +#if os(iOS) isAccessibilityElement = true accessibilityTraits = super.accessibilityTraits.union(.button) shouldGroupAccessibilityChildren = true +#endif } - private func updateAppearance() { - key.backgroundColor = isEnabled ? .white : .dark.withAlphaComponent(0.8) + private func updateAccessibility() { +#if os(iOS) accessibilityTraits = isEnabled ? [.button] : [.button, .notEnabled] if isSelected { @@ -181,6 +200,7 @@ final class WhiteKey: UIControl { } else { accessibilityTraits.remove(.selected) } +#endif } private func setUpViewHierarchy() { @@ -219,7 +239,7 @@ extension WhiteKey: TipHandler { edge: .leftBottom, title: tip.title, message: tip.message, - action: TipView.Action(title: tip.actionTitle) { [weak self] in + action: TipView.TipViewAction(title: tip.actionTitle) { [weak self] in self?.dismissTipView() }, onView: self @@ -238,6 +258,12 @@ extension WhiteKey: TipHandler { } } -extension UIControl.Event { - static let tipDismissed: UIControl.Event = .applicationReserved +extension Control.Event { +#if os(iOS) + static let tipDismissed: Control.Event = .applicationReserved +#endif + +#if os(macOS) + static let tipDismissed: Control.Event = .init(rawValue: 3) +#endif } diff --git a/Bemol/Views/NavBar/AccuracyRing.swift b/Shared/Views/NavBar/AccuracyRing.swift similarity index 59% rename from Bemol/Views/NavBar/AccuracyRing.swift rename to Shared/Views/NavBar/AccuracyRing.swift index e71ee11..97fe033 100644 --- a/Bemol/Views/NavBar/AccuracyRing.swift +++ b/Shared/Views/NavBar/AccuracyRing.swift @@ -17,9 +17,16 @@ /// import Foundation + +#if os(macOS) +import AppKit +#endif + +#if os(iOS) import UIKit +#endif -final class AccuracyRing: UIControl { +final class AccuracyRing: Control { // MARK: - Layers private lazy var backgroundLayer: CAShapeLayer = { @@ -42,28 +49,30 @@ final class AccuracyRing: UIControl { // MARK: - Subviews - private lazy var rings: UIView = { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - view.layer.addSublayer(backgroundLayer) - view.layer.addSublayer(foregroundLayer) - view.transform = CGAffineTransform(rotationAngle: .pi * 3 / 2) + private lazy var rings: View = { + let view = View() + view.setUp() + view.addSublayer(backgroundLayer) + view.addSublayer(foregroundLayer) view.isUserInteractionEnabled = false return view }() - private lazy var label: UILabel = { - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false + private lazy var label: Label = { + let label = Label() + label.setUp() label.font = .boldFootnote label.textColor = color label.textAlignment = .center label.adjustsFontForContentSizeCategory = true label.adjustsFontSizeToFitWidth = true - label.maximumContentSizeCategory = .accessibilityMedium label.isUserInteractionEnabled = false +#if os(iOS) + label.maximumContentSizeCategory = .accessibilityMedium +#endif + return label }() @@ -74,16 +83,20 @@ final class AccuracyRing: UIControl { let formatter = NumberFormatter() formatter.numberStyle = .percent label.text = formatter.string(from: NSNumber(floatLiteral: Double(accuracy))) - self.color = UIColor.color(for: Double(accuracy)) + self.color = Color.color(for: Double(accuracy)) setNeedsLayout() } } - // MARK: - Properties + var strokeWidth: CGFloat = 6 { + didSet { + setNeedsLayout() + } + } - private let strokeWidth: CGFloat = 6 + // MARK: - Properties - private var color: UIColor = .systemTeal { + private var color: Color = .systemTeal { didSet { label.textColor = color } } @@ -92,55 +105,78 @@ final class AccuracyRing: UIControl { override init(frame: CGRect) { super.init(frame: frame) setUpViewHierarchy() + +#if os(iOS) isAccessibilityElement = true accessibilityTraits = super.accessibilityTraits.union(.button) +#endif } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - Tracking - override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + override func beginTracking(_ touch: Touch, with event: UIEvent?) -> Bool { transform = CGAffineTransform(scaleX: 0.9, y: 0.9) return true } - override func endTracking(_ touch: UITouch?, with event: UIEvent?) { + override func endTracking(_ touch: Touch?, with event: UIEvent?) { transform = CGAffineTransform(scaleX: 1, y: 1) } // MARK: - Layout +#if os(iOS) override func layoutSubviews() { super.layoutSubviews() + performLayout() + } +#endif + +#if os(macOS) + override func layout() { + super.layout() + performLayout() + } +#endif - let backgroundPath = UIBezierPath( - arcCenter: CGPoint(x: frame.width / 2, y: frame.height / 2), - radius: frame.width / 2, + private func performLayout() { + let backgroundPath = BezierPath( + arcCenter: CGPoint(x: rings.frame.width / 2, y: rings.frame.height / 2), + radius: rings.frame.width / 2, startAngle: 0, endAngle: .pi * 2, clockwise: true ) - let foregroundPath = UIBezierPath( - arcCenter: CGPoint(x: frame.width / 2, y: frame.height / 2), - radius: frame.width / 2, + let foregroundPath = BezierPath( + arcCenter: CGPoint(x: rings.frame.width / 2, y: rings.frame.height / 2), + radius: rings.frame.width / 2, startAngle: 0, endAngle: CGFloat(accuracy) * (.pi * 2), clockwise: true ) - backgroundLayer.frame = bounds + let color = isEnabled ? self.color : Color.lightGray + + backgroundLayer.frame = rings.bounds backgroundLayer.lineWidth = strokeWidth backgroundLayer.strokeColor = color.withAlphaComponent(0.5).cgColor backgroundLayer.path = backgroundPath.cgPath + backgroundLayer.fillColor = Color.clear.cgColor - foregroundLayer.frame = bounds + foregroundLayer.frame = rings.bounds foregroundLayer.lineWidth = strokeWidth foregroundLayer.strokeColor = color.cgColor foregroundLayer.path = foregroundPath.cgPath + foregroundLayer.fillColor = Color.clear.cgColor + + label.textColor = color + + rings.rotate(by: .pi * 3 / 2) } // MARK: - Private @@ -149,18 +185,35 @@ final class AccuracyRing: UIControl { addSubview(rings) addSubview(label) - NSLayoutConstraint.activate([ + LayoutConstraint.activate([ rings.leadingAnchor.constraint(equalTo: leadingAnchor), rings.topAnchor.constraint(equalTo: topAnchor), - rings.trailingAnchor.constraint(equalTo: trailingAnchor), + rings.widthAnchor.constraint(equalTo: heightAnchor), rings.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) +#if os(iOS) + LayoutConstraint.activate([ label.centerXAnchor.constraint(equalTo: centerXAnchor), label.centerYAnchor.constraint(equalTo: centerYAnchor), - label.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: (strokeWidth + 2)), + label.leadingAnchor.constraint( + greaterThanOrEqualTo: leadingAnchor, constant: (strokeWidth + 2)), + label.topAnchor.constraint(lessThanOrEqualTo: topAnchor, constant: (strokeWidth + 2)), + label.trailingAnchor.constraint( + lessThanOrEqualTo: trailingAnchor, constant: -(strokeWidth + 2)), + label.bottomAnchor.constraint( + greaterThanOrEqualTo: bottomAnchor, constant: -(strokeWidth + 2)), + ]) +#endif + +#if os(macOS) + LayoutConstraint.activate([ + label.centerYAnchor.constraint(equalTo: centerYAnchor), + label.leadingAnchor.constraint(equalTo: rings.trailingAnchor, constant: .spacingXs), label.topAnchor.constraint(lessThanOrEqualTo: topAnchor, constant: (strokeWidth + 2)), - label.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -(strokeWidth + 2)), - label.bottomAnchor.constraint(greaterThanOrEqualTo: bottomAnchor, constant: -(strokeWidth + 2)), + label.bottomAnchor.constraint( + greaterThanOrEqualTo: bottomAnchor, constant: -(strokeWidth + 2)), ]) +#endif } } diff --git a/Bemol/Views/NavBar/NavBar.swift b/Shared/Views/NavBar/NavBar.swift similarity index 92% rename from Bemol/Views/NavBar/NavBar.swift rename to Shared/Views/NavBar/NavBar.swift index f676b1d..d4edea4 100644 --- a/Bemol/Views/NavBar/NavBar.swift +++ b/Shared/Views/NavBar/NavBar.swift @@ -17,7 +17,15 @@ /// import Foundation + +#if os(macOS) +import AppKit +#endif + +#if os(iOS) import UIKit +#endif + struct NavBarDelegate { let didPressHomeButton: () -> Void @@ -31,6 +39,14 @@ struct NavBarDelegate { let didDismissTip: () -> Void } +#if os(macOS) +final class NavBar: NSView { + var delegate: NavBarDelegate? + var state: NavBarState? +} +#endif + +#if os(iOS) @MainActor final class NavBar: UIView { // MARK: - Subviews @@ -57,7 +73,6 @@ final class NavBar: UIView { for: .touchUpInside ) - return button }() @@ -90,7 +105,6 @@ final class NavBar: UIView { for: .touchUpInside ) - return button }() @@ -253,11 +267,12 @@ final class NavBar: UIView { titleView.title = state?.title configureButton.isEnabled = isConfigureButtonEnabled && !isLoading startStopButton.isEnabled = isStartStopButtonEnabled && !isLoading - startStopButton.configuration?.image = if (state?.startStopButtonMode ?? .start) == .start { - UIImage(systemName: "play.fill") - } else { - UIImage(systemName: "stop.fill") - } + startStopButton.configuration?.image = + if (state?.startStopButtonMode ?? .start) == .start { + UIImage(systemName: "play.fill") + } else { + UIImage(systemName: "stop.fill") + } repeatButton.isHidden = state?.isRepeatButtonHidden ?? true repeatButton.isEnabled = state?.isRepeatButtonEnabled ?? false scoreLabel.isHidden = state?.isScoreLabelHidden ?? true @@ -273,11 +288,12 @@ final class NavBar: UIView { loadingIndicator.stopAnimating() } - startStopButton.accessibilityLabel = if (state?.startStopButtonMode ?? .start) == .start { - String(localized: "startSession") - } else { - String(localized: "stopSession") - } + startStopButton.accessibilityLabel = + if (state?.startStopButtonMode ?? .start) == .start { + String(localized: "startSession") + } else { + String(localized: "stopSession") + } if let tip = state?.tip { if oldValue?.tip == nil { @@ -310,7 +326,7 @@ final class NavBar: UIView { accessibilityTraits = .header accessibilityElements = [ homeButton, randomButton, previousButton, nextButton, titleView, - configureButton, startStopButton, repeatButton, scoreLabel, accuracyRing + configureButton, startStopButton, repeatButton, scoreLabel, accuracyRing, ] } @@ -328,9 +344,11 @@ final class NavBar: UIView { accuracyRing.widthAnchor.constraint(equalTo: accuracyRing.heightAnchor), accuracyRing.centerYAnchor.constraint(equalTo: centerYAnchor), - scoreLabel.trailingAnchor.constraint(equalTo: accuracyRing.leadingAnchor, constant: -.spacingSm), + scoreLabel.trailingAnchor.constraint( + equalTo: accuracyRing.leadingAnchor, constant: -.spacingSm), scoreLabel.centerYAnchor.constraint(equalTo: centerYAnchor), - scoreLabel.leadingAnchor.constraint(greaterThanOrEqualTo: stackView.trailingAnchor, constant: .spacingSm), + scoreLabel.leadingAnchor.constraint( + greaterThanOrEqualTo: stackView.trailingAnchor, constant: .spacingSm), ]) } @@ -360,7 +378,7 @@ extension NavBar: TipHandler { edge: edge(for: tip), title: tip.title, message: tip.message, - action: TipView.Action(title: tip.actionTitle) { [weak self] in + action: TipView.TipViewAction(title: tip.actionTitle) { [weak self] in self?.dismissTipView() }, onView: view @@ -406,25 +424,26 @@ extension NavBar: TipHandler { private func edge(for tip: Tip) -> TipView.Edge { switch tip.target { case .titleView: - .topCenter + .topCenter case .homeButton: - .topLeft + .topLeft case .randomButton: - .topLeft + .topLeft case .previousButton: - .topLeft + .topLeft case .nextButton: - .topCenter + .topCenter case .configureLevelButton: - .topCenter + .topCenter case .startStopButton: - .topCenter + .topCenter case .repeatButton: - .topCenter + .topCenter case .accuracyRing: - .topRight + .topRight case .keyboard: - .topCenter + .topCenter } } } +#endif diff --git a/Bemol/Views/NavBar/NavBarState.swift b/Shared/Views/NavBar/NavBarState.swift similarity index 100% rename from Bemol/Views/NavBar/NavBarState.swift rename to Shared/Views/NavBar/NavBarState.swift diff --git a/Bemol/Views/NavBar/TitleView.swift b/Shared/Views/NavBar/TitleView.swift similarity index 84% rename from Bemol/Views/NavBar/TitleView.swift rename to Shared/Views/NavBar/TitleView.swift index 34e214e..d09ffcd 100644 --- a/Bemol/Views/NavBar/TitleView.swift +++ b/Shared/Views/NavBar/TitleView.swift @@ -17,23 +17,30 @@ /// import Foundation + +#if os(macOS) +import AppKit +#endif + +#if os(iOS) import UIKit +#endif -final class TitleView: UIView { +final class TitleView: View { // MARK: - Subviews - private lazy var background: UIView = { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - view.backgroundColor = .dark - view.layer.cornerRadius = .cornerRadiusSm + private lazy var background: View = { + let view = View() + view.setUp() + view.backgroundStyle = .dark + view.cornerRadius = .cornerRadiusSm return view }() - private lazy var label: UILabel = { - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false + private lazy var label: Label = { + let label = Label() + label.setUp() label.font = .boldFootnote label.textAlignment = .center label.textColor = .buttonForeground @@ -59,7 +66,7 @@ final class TitleView: UIView { var title: AttributedString? { didSet { label.attributedText = title.flatMap { NSAttributedString($0) } - accessibilityLabel = title?.description + setAccessibilityLabel(title?.description) } } @@ -70,7 +77,7 @@ final class TitleView: UIView { setUpViewHierarchy() setUpAppearance() } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -78,7 +85,9 @@ final class TitleView: UIView { // MARK: - Private private func setUpAppearance() { +#if os(iOS) accessibilityTraits = .staticText +#endif } private func setUpViewHierarchy() { @@ -96,7 +105,8 @@ final class TitleView: UIView { label.centerYAnchor.constraint(equalTo: background.centerYAnchor), label.trailingAnchor.constraint(equalTo: background.trailingAnchor, constant: -.spacingSm), label.topAnchor.constraint(lessThanOrEqualTo: background.topAnchor, constant: .spacingXxs), - label.bottomAnchor.constraint(greaterThanOrEqualTo: background.bottomAnchor, constant: -.spacingXxs), + label.bottomAnchor.constraint( + greaterThanOrEqualTo: background.bottomAnchor, constant: -.spacingXxs), ]) } } diff --git a/Bemol/Views/Tip/TipPresenter.swift b/Shared/Views/Tip/TipPresenter.swift similarity index 84% rename from Bemol/Views/Tip/TipPresenter.swift rename to Shared/Views/Tip/TipPresenter.swift index 3b1e572..65a0a29 100644 --- a/Bemol/Views/Tip/TipPresenter.swift +++ b/Shared/Views/Tip/TipPresenter.swift @@ -16,7 +16,13 @@ /// If not, see . /// +#if os(macOS) +import AppKit +#endif + +#if os(iOS) import UIKit +#endif @MainActor final class TipPresenter { @@ -26,10 +32,16 @@ final class TipPresenter { edge: TipView.Edge, title: String, message: String, - action: TipView.Action, - onView view: UIView + action: TipView.TipViewAction, + onView view: View ) -> TipView? { +#if os(iOS) guard let window = view.window else { return nil } +#endif + +#if os(macOS) + guard let window = view.window?.contentView else { return nil } +#endif let targetViewFrame = view.convert(view.bounds, to: window) let tipView = TipView( @@ -62,7 +74,8 @@ final class TipPresenter { ) case .topRight: tipView.frame = CGRect( - x: targetViewFrame.midX - tipView.intrinsicContentSize.width + (.spacingSm + .caretSize / 2), + x: targetViewFrame.midX - tipView.intrinsicContentSize.width + + (.spacingSm + .caretSize / 2), y: targetViewFrame.maxY + .spacingXs, width: tipView.intrinsicContentSize.width, height: tipView.intrinsicContentSize.height @@ -76,6 +89,11 @@ final class TipPresenter { ) } +#if os(macOS) + tipView.alpha = 1 +#endif + +#if os(iOS) UIView.animate( withDuration: .animationDuration, delay: 0, @@ -89,12 +107,20 @@ final class TipPresenter { } } ) +#endif return tipView } - static private func preferredMaxWidth(for tipView: TipView, on view: UIView) -> CGFloat { + static private func preferredMaxWidth(for tipView: TipView, on view: View) -> CGFloat { +#if os(iOS) guard let window = view.window else { return .tipViewDefaultMaxWidth } +#endif + +#if os(macOS) + guard let window = view.window?.contentView else { return .tipViewDefaultMaxWidth } +#endif + let bounds = view.convert(view.bounds, to: window) switch tipView.edge { @@ -113,11 +139,18 @@ final class TipPresenter { static func dismiss(_ tipView: TipView, completion: @escaping () -> Void) { guard tipView.superview != nil else { + #if os(iOS) UIAccessibility.post(notification: .screenChanged, argument: nil) + #endif completion() return } +#if os(macOS) + tipView.alpha = 0 +#endif + +#if os(iOS) UIView.animate( withDuration: .animationDuration, delay: 0, @@ -136,15 +169,16 @@ final class TipPresenter { completion() } ) +#endif } } // MARK: - Constants -private extension TimeInterval { - static let animationDuration: TimeInterval = 0.15 +extension TimeInterval { + fileprivate static let animationDuration: TimeInterval = 0.15 } -private extension CGFloat { - static let caretSize: CGFloat = 12 +extension CGFloat { + fileprivate static let caretSize: CGFloat = 12 } diff --git a/Bemol/Views/Tip/TipView.swift b/Shared/Views/Tip/TipView.swift similarity index 73% rename from Bemol/Views/Tip/TipView.swift rename to Shared/Views/Tip/TipView.swift index 673cca4..27382dc 100644 --- a/Bemol/Views/Tip/TipView.swift +++ b/Shared/Views/Tip/TipView.swift @@ -16,9 +16,17 @@ /// If not, see . /// +import Foundation + +#if os(macOS) +import AppKit +#endif + +#if os(iOS) import UIKit +#endif -final class TipView: UIView { +final class TipView: View { enum Edge { case topLeft case topCenter @@ -26,29 +34,29 @@ final class TipView: UIView { case leftBottom } - struct Action { + struct TipViewAction { let title: String let perform: () -> Void } // MARK: - Subviews - private lazy var caret: UIView = { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - view.backgroundColor = .tooltip + private lazy var caret: View = { + let view = View() + view.setUp() + view.backgroundStyle = .tooltip view.widthAnchor.constraint(equalToConstant: .caretSize).isActive = true view.heightAnchor.constraint(equalToConstant: .caretSize).isActive = true - view.layer.cornerRadius = .cornerRadiusSm + view.cornerRadius = .cornerRadiusSm view.transform = CGAffineTransform(rotationAngle: .pi / 4) view.isUserInteractionEnabled = false return view }() - private lazy var titleLabel: UIView = { - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false + private lazy var titleLabel: Label = { + let label = Label() + label.setUp() label.font = .boldFootnote label.text = title label.textColor = .tooltipText @@ -56,15 +64,18 @@ final class TipView: UIView { label.numberOfLines = 1 label.adjustsFontSizeToFitWidth = true label.adjustsFontForContentSizeCategory = true - label.maximumContentSizeCategory = .extraLarge label.isUserInteractionEnabled = false +#if os(iOS) + label.maximumContentSizeCategory = .extraLarge +#endif + return label }() - private lazy var messageLabel: UIView = { - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false + private lazy var messageLabel: Label = { + let label = Label() + label.setUp() label.font = .footnote label.text = message label.textColor = .tooltipText @@ -72,21 +83,22 @@ final class TipView: UIView { label.numberOfLines = 0 label.adjustsFontSizeToFitWidth = true label.adjustsFontForContentSizeCategory = true - label.maximumContentSizeCategory = .extraLarge label.isUserInteractionEnabled = false label.widthAnchor.constraint(greaterThanOrEqualToConstant: .labelMinWidth).isActive = true +#if os(iOS) + label.maximumContentSizeCategory = .extraLarge +#endif + + return label }() - private lazy var button: UIButton = { - let button = UIButton(configuration: .plain()) - button.translatesAutoresizingMaskIntoConstraints = false - button.configuration?.buttonSize = .small - button.configuration?.baseForegroundColor = .systemOrange - button.configuration?.title = action.title + private lazy var button: Button = { + let button = Button.primary(title: action.title) + button.setUp() button.addAction( - UIAction { [weak self] _ in + Action { [weak self] _ in self?.action.perform() }, for: .touchUpInside @@ -96,17 +108,17 @@ final class TipView: UIView { return button }() - private lazy var bubble: UIView = { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - view.backgroundColor = .tooltip - view.layer.cornerRadius = .cornerRadiusMd + private lazy var bubble: View = { + let view = View() + view.setUp() + view.backgroundStyle = .tooltip + view.cornerRadius = .cornerRadiusMd view.isUserInteractionEnabled = false - view.layer.shadowColor = UIColor.black.cgColor - view.layer.shadowRadius = 10 - view.layer.shadowOffset = CGSize(width: 0, height: 3) - view.layer.shadowOpacity = 0.15 - view.layer.masksToBounds = false + view.shadowColor = Color.black.cgColor + view.shadowRadius = 10 + view.shadowOffset = CGSize(width: 0, height: 3) + view.shadowOpacity = 0.15 + view.masksToBounds = false return view }() @@ -130,9 +142,7 @@ final class TipView: UIView { override var intrinsicContentSize: CGSize { return CGSize( - width: messageLabel.systemLayoutSizeFitting( - UIView.layoutFittingCompressedSize - ).width + .labelHorizontalMargin * 2, + width: messageLabel.fittingSize.width + .labelHorizontalMargin * 2, height: (.caretSize / 2) + (messageLabel.intrinsicContentSize.height + .labelVerticalMargin * 2) + .caretSize / 4 @@ -144,11 +154,11 @@ final class TipView: UIView { let edge: Edge let title: String let message: String - let action: Action + let action: TipViewAction // MARK: - Initialization - init(edge: Edge, title: String, message: String, action: Action) { + init(edge: Edge, title: String, message: String, action: TipViewAction) { self.edge = edge self.title = title self.message = message @@ -157,25 +167,37 @@ final class TipView: UIView { setUpViewHierarchy() setUpAppearance() } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - Hit Testing - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + #if os(iOS) + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> View? { if button.frame.contains(point) { return button } return super.hitTest(point, with: event) } +#endif + +#if os(macOS) + override func hitTest(_ point: NSPoint) -> NSView? { + if button.frame.contains(point) { + return button + } + + return super.hitTest(point) + } +#endif // MARK: - Private Helpers private func setUpAppearance() { - accessibilityViewIsModal = true + setUpAccessibility() } private func setUpViewHierarchy() { @@ -185,19 +207,21 @@ final class TipView: UIView { addSubview(messageLabel) addSubview(button) - NSLayoutConstraint.activate([ - bubble.leadingAnchor.constraint(equalTo: messageLabel.leadingAnchor, constant: -.labelHorizontalMargin), - bubble.trailingAnchor.constraint(equalTo: messageLabel.trailingAnchor, constant: .labelHorizontalMargin), + LayoutConstraint.activate([ + bubble.leadingAnchor.constraint( + equalTo: messageLabel.leadingAnchor, constant: -.labelHorizontalMargin), + bubble.trailingAnchor.constraint( + equalTo: messageLabel.trailingAnchor, constant: .labelHorizontalMargin), titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: .spacingSm), messageLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), button.trailingAnchor.constraint(equalTo: bubble.trailingAnchor, constant: -.spacingSm), - labelMaxWidthConstraint + labelMaxWidthConstraint, ]) switch edge { case .topLeft: - caret.layer.maskedCorners = [.layerMinXMinYCorner] - NSLayoutConstraint.activate([ + caret.maskedCorners = [.layerMinXMinYCorner] + LayoutConstraint.activate([ caret.topAnchor.constraint(equalTo: topAnchor, constant: .caretSize / 4), caret.leadingAnchor.constraint(equalTo: bubble.leadingAnchor, constant: .spacingSm), bubble.topAnchor.constraint(equalTo: caret.bottomAnchor, constant: -.caretSize / 2), @@ -207,8 +231,8 @@ final class TipView: UIView { bubble.bottomAnchor.constraint(equalTo: button.bottomAnchor, constant: .spacingXs), ]) case .topCenter: - caret.layer.maskedCorners = [.layerMinXMinYCorner] - NSLayoutConstraint.activate([ + caret.maskedCorners = [.layerMinXMinYCorner] + LayoutConstraint.activate([ caret.topAnchor.constraint(equalTo: topAnchor, constant: .caretSize / 4), caret.centerXAnchor.constraint(equalTo: centerXAnchor), bubble.topAnchor.constraint(equalTo: caret.bottomAnchor, constant: -.caretSize / 2), @@ -218,8 +242,8 @@ final class TipView: UIView { bubble.bottomAnchor.constraint(equalTo: button.bottomAnchor, constant: .spacingXs), ]) case .topRight: - caret.layer.maskedCorners = [.layerMinXMinYCorner] - NSLayoutConstraint.activate([ + caret.maskedCorners = [.layerMinXMinYCorner] + LayoutConstraint.activate([ caret.topAnchor.constraint(equalTo: topAnchor, constant: .caretSize / 4), caret.trailingAnchor.constraint(equalTo: bubble.trailingAnchor, constant: -.spacingSm), bubble.topAnchor.constraint(equalTo: caret.bottomAnchor, constant: -.caretSize / 2), @@ -229,8 +253,8 @@ final class TipView: UIView { bubble.bottomAnchor.constraint(equalTo: button.bottomAnchor, constant: .spacingXs), ]) case .leftBottom: - caret.layer.maskedCorners = [.layerMinXMaxYCorner] - NSLayoutConstraint.activate([ + caret.maskedCorners = [.layerMinXMaxYCorner] + LayoutConstraint.activate([ caret.leadingAnchor.constraint(equalTo: leadingAnchor, constant: -.caretSize / 2), caret.bottomAnchor.constraint(equalTo: bubble.bottomAnchor, constant: -.spacingSm), bubble.topAnchor.constraint(equalTo: topAnchor), @@ -241,17 +265,24 @@ final class TipView: UIView { ]) } } + + private func setUpAccessibility() { +#if os(iOS) + accessibilityViewIsModal = true +#endif + } } // MARK: - Reusable constants @MainActor -private extension CGFloat { - static let caretSize: CGFloat = 12 - static let labelHorizontalMargin: CGFloat = .spacingSm - static let labelVerticalMargin: CGFloat = .spacingSm - static let labelMaxWidth: CGFloat = tipViewDefaultMaxWidth - (.labelHorizontalMargin * 2) - static let labelMinWidth: CGFloat = 64 - (.labelHorizontalMargin * 2) +extension CGFloat { + fileprivate static let caretSize: CGFloat = 12 + fileprivate static let labelHorizontalMargin: CGFloat = .spacingSm + fileprivate static let labelVerticalMargin: CGFloat = .spacingSm + fileprivate static let labelMaxWidth: CGFloat = + tipViewDefaultMaxWidth - (.labelHorizontalMargin * 2) + fileprivate static let labelMinWidth: CGFloat = 64 - (.labelHorizontalMargin * 2) } extension CGFloat { diff --git a/Bemol/Views/TitleBar/TitleBar.swift b/Shared/Views/TitleBar/TitleBar.swift similarity index 62% rename from Bemol/Views/TitleBar/TitleBar.swift rename to Shared/Views/TitleBar/TitleBar.swift index f89ec09..23245b1 100644 --- a/Bemol/Views/TitleBar/TitleBar.swift +++ b/Shared/Views/TitleBar/TitleBar.swift @@ -17,37 +17,39 @@ /// import Foundation + +#if os(macOS) +import AppKit +#endif + +#if os(iOS) import UIKit +#endif + struct TitleBarDelegate { let didPressCancelButton: () -> Void let didPressDoneButton: () -> Void } + @MainActor -final class TitleBar: UIView { +final class TitleBar: View { // MARK: - Subviews - private lazy var cancelButton: UIButton = { - let button = UIButton(configuration: .plain()) - button.translatesAutoresizingMaskIntoConstraints = false - button.configuration?.title = String(localized: "cancel") - button.configuration?.baseForegroundColor = .systemOrange - button.configuration?.titleAlignment = .center - button.configuration?.buttonSize = .small - button.setContentHuggingPriority(.required, for: .horizontal) - button.setContentCompressionResistancePriority(.required, for: .horizontal) + private lazy var cancelButton: Button = { + let button = Button.secondary(title: String(localized: "cancel")) + button.setUp() button.addAction( - UIAction { [weak self] _ in self?.delegate?.didPressCancelButton() }, + Action { [weak self] _ in self?.delegate?.didPressCancelButton() }, for: .touchUpInside ) - return button }() - private lazy var label: UILabel = { - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false + private lazy var label: Label = { + let label = Label() + label.setUp() label.font = .body label.textAlignment = .center label.textColor = .buttonForeground @@ -57,18 +59,11 @@ final class TitleBar: UIView { return label }() - private lazy var doneButton: UIButton = { - let button = UIButton(configuration: .filled()) - button.translatesAutoresizingMaskIntoConstraints = false - button.configuration?.title = String(localized: "done") - button.configuration?.baseBackgroundColor = .systemOrange - button.configuration?.baseForegroundColor = .buttonForeground - button.configuration?.titleAlignment = .center - button.configuration?.buttonSize = .small - button.setContentHuggingPriority(.required, for: .horizontal) - button.setContentCompressionResistancePriority(.required, for: .horizontal) + private lazy var doneButton: Button = { + let button = Button.primary(title: String(localized: "done")) + button.setUp() button.addAction( - UIAction { [weak self] _ in self?.delegate?.didPressDoneButton() }, + Action { [weak self] _ in self?.delegate?.didPressDoneButton() }, for: .touchUpInside ) @@ -120,7 +115,7 @@ final class TitleBar: UIView { // MARK: - Private private func setUpAppearance() { - backgroundColor = .black + backgroundStyle = .black } private func setUpViewHierarchy() { @@ -132,12 +127,14 @@ final class TitleBar: UIView { cancelButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: .spacingMd), cancelButton.centerYAnchor.constraint(equalTo: centerYAnchor), - label.leadingAnchor.constraint(equalTo: cancelButton.trailingAnchor, constant: .spacingMd), + label.leadingAnchor.constraint(greaterThanOrEqualTo: cancelButton.trailingAnchor, constant: .spacingMd), + label.centerXAnchor.constraint(equalTo: centerXAnchor), label.centerYAnchor.constraint(equalTo: centerYAnchor), - label.trailingAnchor.constraint(equalTo: doneButton.leadingAnchor, constant: -.spacingMd), + label.trailingAnchor.constraint(lessThanOrEqualTo: doneButton.leadingAnchor, constant: -.spacingMd), doneButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -.spacingMd), doneButton.centerYAnchor.constraint(equalTo: centerYAnchor), ]) } } + diff --git a/BemolTests/AppLoopTests.swift b/Tests/AppLoopTests.swift similarity index 51% rename from BemolTests/AppLoopTests.swift rename to Tests/AppLoopTests.swift index 7143a0f..c492fa1 100644 --- a/BemolTests/AppLoopTests.swift +++ b/Tests/AppLoopTests.swift @@ -16,9 +16,10 @@ /// If not, see . /// +import AppKit import Foundation -import os import Testing +import os @testable import Bemol @@ -26,40 +27,25 @@ import Testing struct AppLoopTests { @Test func didLoad() async throws { - let notePlayer = MockNotePlayer() - let practiceManager = MockPracticeManager() - let environment = makeAppEnvironment(notePlayer: notePlayer, practiceManager: practiceManager) + let environment = makeAppEnvironment() let state = AppState() let loop = AppLoop(environment: environment, initialState: state) let (nextState, effect) = loop.nextState(currentState: state, action: .didLoad) #expect(nextState.isLoading == true) - - let task = Task { - try await effect?.run() - } - - let action = try await task.value - - await #expect(notePlayer.isPrepareToPlayCalled == true) - await #expect(practiceManager.isPrepareToPracticeCalled == true) - - guard case .didLoadLevel = action else { - Issue.record("expected next action to be `didLoadLevel` after `didLoad`") - return - } + #expect(effect == .prepareToPractice) } @Test - func didLoadLevelSuccessfully() async throws { + func didLoadLevel() async throws { let environment = makeAppEnvironment() let state = AppState(isLoading: true) let loop = AppLoop(environment: environment, initialState: state) let (nextState, effect) = loop.nextState( currentState: state, - action: .didLoadLevel(.success(makeLevel(id: 42))) + action: .didLoadLevel(makeLevel(id: 42)) ) #expect(nextState.isLoading == false) @@ -73,138 +59,56 @@ struct AppLoopTests { #expect(nextState.currentTip == nil) #expect(nextState.accuracy == 0) #expect(nextState.accuracyPerNote.isEmpty == true) - - #expect(effect == nil) - } - - @Test - func didLoadLevelFails() async throws { - let environment = makeAppEnvironment() - let state = AppState(isLoading: true) - let loop = AppLoop(environment: environment, initialState: state) - - let (nextState, effect) = loop.nextState( - currentState: state, - action: .didLoadLevel(.failure(MockError.error)) - ) - - #expect(nextState.isLoading == false) - #expect(nextState.hasError == true) - #expect(nextState.error as? MockError == MockError.error) - #expect(effect == nil) } @Test func didPressHomeButton() async throws { - let practiceManager = MockPracticeManager() - let environment = makeAppEnvironment(practiceManager: practiceManager) + let environment = makeAppEnvironment() let state = AppState() let loop = AppLoop(environment: environment, initialState: state) let (nextState, effect) = loop.nextState(currentState: state, action: .didPressHomeButton) #expect(nextState.isLoading == true) - - let task = Task { - try await effect?.run() - } - - let action = try await task.value - - await #expect(practiceManager.isMoveToFirstLevelCalled == true) - await #expect(practiceManager.isMoveToNextLevelCalled == false) - await #expect(practiceManager.isMoveToPreviousLevelCalled == false) - await #expect(practiceManager.isMoveToRandomLevelCalled == false) - - guard case .didLoadLevel = action else { - Issue.record("expected next action to be `didLoadLevel` after `didPressHomeButton`") - return - } + #expect(effect == .loadFirstLevel) } @Test func didPressRandomLevelButton() async throws { - let practiceManager = MockPracticeManager() - let environment = makeAppEnvironment(practiceManager: practiceManager) + let environment = makeAppEnvironment() let state = AppState() let loop = AppLoop(environment: environment, initialState: state) let (nextState, effect) = loop.nextState(currentState: state, action: .didPressRandomButton) #expect(nextState.isLoading == true) - - let task = Task { - try await effect?.run() - } - - let action = try await task.value - - await #expect(practiceManager.isMoveToRandomLevelCalled == true) - await #expect(practiceManager.isMoveToNextLevelCalled == false) - await #expect(practiceManager.isMoveToPreviousLevelCalled == false) - await #expect(practiceManager.isMoveToFirstLevelCalled == false) - - guard case .didLoadLevel = action else { - Issue.record("expected next action to be `didLoadLevel` after `didPressRandomLevelButton`") - return - } + #expect(effect == .loadRandomLevel) } @Test func didPressPreviousLevelButton() async throws { - let practiceManager = MockPracticeManager() - let environment = makeAppEnvironment(practiceManager: practiceManager) + let environment = makeAppEnvironment() let state = AppState() let loop = AppLoop(environment: environment, initialState: state) - let (nextState, effect) = loop.nextState(currentState: state, action: .didPressPreviousLevelButton) + let (nextState, effect) = loop.nextState( + currentState: state, action: .didPressPreviousLevelButton) #expect(nextState.isLoading == true) - - let task = Task { - try await effect?.run() - } - - let action = try await task.value - - await #expect(practiceManager.isMoveToPreviousLevelCalled == true) - await #expect(practiceManager.isMoveToNextLevelCalled == false) - await #expect(practiceManager.isMoveToFirstLevelCalled == false) - await #expect(practiceManager.isMoveToRandomLevelCalled == false) - - guard case .didLoadLevel = action else { - Issue.record("expected next action to be `didLoadLevel` after `didPressPreviousLevelButton`") - return - } + #expect(effect == .loadPreviousLevel) } @Test func didPressNextLevelButton() async throws { - let practiceManager = MockPracticeManager() - let environment = makeAppEnvironment(practiceManager: practiceManager) + let environment = makeAppEnvironment() let state = AppState() let loop = AppLoop(environment: environment, initialState: state) let (nextState, effect) = loop.nextState(currentState: state, action: .didPressNextLevelButton) #expect(nextState.isLoading == true) - - let task = Task { - try await effect?.run() - } - - let action = try await task.value - - await #expect(practiceManager.isMoveToNextLevelCalled == true) - await #expect(practiceManager.isMoveToFirstLevelCalled == false) - await #expect(practiceManager.isMoveToPreviousLevelCalled == false) - await #expect(practiceManager.isMoveToRandomLevelCalled == false) - - guard case .didLoadLevel = action else { - Issue.record("expected next action to be `didLoadLevel` after `didPressNextLevelButton`") - return - } + #expect(effect == .loadNextLevel) } @Test @@ -224,8 +128,7 @@ struct AppLoopTests { @Test func didPressStartStopButtonWhenNotPracticing() async throws { - let practiceManager = MockPracticeManager() - let environment = makeAppEnvironment(practiceManager: practiceManager) + let environment = makeAppEnvironment() let state = AppState(isPracticing: false) let loop = AppLoop(environment: environment, initialState: state) @@ -236,26 +139,12 @@ struct AppLoopTests { #expect(nextState.isPracticing == true) #expect(nextState.highlightedNote == nil) - - let task = Task { - try await effect?.run() - } - - let action = try await task.value - - await #expect(practiceManager.isStartSessionCalled == true) - await #expect(practiceManager.isStopSessionCalled == false) - - guard case .didStartSession = action else { - Issue.record("expected next action to be `didStartSession` after `didPressStartButton`") - return - } + #expect(effect == .startSession) } @Test func didPressStartStopButtonWhenPracticing() async throws { - let practiceManager = MockPracticeManager() - let environment = makeAppEnvironment(practiceManager: practiceManager) + let environment = makeAppEnvironment() let state = AppState(isPracticing: true) let loop = AppLoop(environment: environment, initialState: state) @@ -266,33 +155,19 @@ struct AppLoopTests { #expect(nextState.isPracticing == false) #expect(nextState.highlightedNote == nil) - - let task = Task { - try await effect?.run() - } - - let action = try await task.value - - await #expect(practiceManager.isStopSessionCalled == true) - await #expect(practiceManager.isStartSessionCalled == false) - - guard case .didLoadLevel = action else { - Issue.record("expected next action to be `didLoadLevel` after `didPressStopButton`") - return - } + #expect(effect == .stopSession) } @Test - func didStartSessionSuccessfully() async throws { - let practiceManager = MockPracticeManager() - let environment = makeAppEnvironment(practiceManager: practiceManager) + func didStartSession() async throws { + let environment = makeAppEnvironment() let state = AppState(isLoading: true) let loop = AppLoop(environment: environment, initialState: state) let session = makeSession() let (nextState, effect) = loop.nextState( currentState: state, - action: .didStartSession(.success(session)) + action: .didStartSession(session) ) let startedSession = try #require(nextState.session) @@ -306,41 +181,29 @@ struct AppLoopTests { #expect(nextState.highlightedNote == nil) #expect(nextState.isInteractionEnabled == true) - let task = Task { - try await effect?.run() - } - - let action = try await task.value - - await #expect(practiceManager.isMoveToNextQuestionCalled == true) - - guard case .didLoadQuestion = action else { - Issue.record("expected next action to be `didLoadQuestion` after `didStartSession`") - return - } + #expect(effect == .loadNextQuestion) } @Test func didStartSessionPopulatesStats() async throws { - let practiceManager = MockPracticeManager() - let environment = makeAppEnvironment(practiceManager: practiceManager) + let environment = makeAppEnvironment() let state = AppState(isLoading: true) let loop = AppLoop(environment: environment, initialState: state) let session = Session( timestamp: 1000, score: [ - Note(name: .c, octave: 1): (4, 2), - Note(name: .d, octave: 1): (3, 0), - Note(name: .e, octave: 1): (0, 8), - Note(name: .f, octave: 1): (4, 1), - Note(name: .g, octave: 1): (0, 0), - Note(name: .c, octave: 2): (2, 1), + Note(name: .c, octave: 1): .init(correct: 4, wrong: 2), + Note(name: .d, octave: 1): .init(correct: 3, wrong: 0), + Note(name: .e, octave: 1): .init(correct: 0, wrong: 8), + Note(name: .f, octave: 1): .init(correct: 4, wrong: 1), + Note(name: .g, octave: 1): .init(correct: 0, wrong: 0), + Note(name: .c, octave: 2): .init(correct: 2, wrong: 1), ] ) let (nextState, effect) = loop.nextState( currentState: state, - action: .didStartSession(.success(session)) + action: .didStartSession(session) ) let startedSession = try #require(nextState.session) @@ -359,41 +222,29 @@ struct AppLoopTests { #expect(nextState.highlightedNote == nil) #expect(nextState.isInteractionEnabled == true) - let task = Task { - try await effect?.run() - } - - let action = try await task.value - - await #expect(practiceManager.isMoveToNextQuestionCalled == true) - - guard case .didLoadQuestion = action else { - Issue.record("expected next action to be `didLoadQuestion` after `didStartSession`") - return - } + #expect(effect == .loadNextQuestion) } @Test func didLogRightAnswerUpdatesStats() async throws { - let practiceManager = MockPracticeManager() - let environment = makeAppEnvironment(practiceManager: practiceManager) + let environment = makeAppEnvironment() let state = AppState() let loop = AppLoop(environment: environment, initialState: state) let session = Session( timestamp: 1000, score: [ - Note(name: .c, octave: 1): (4, 2), - Note(name: .d, octave: 1): (3, 0), - Note(name: .e, octave: 1): (0, 8), - Note(name: .f, octave: 1): (4, 1), - Note(name: .g, octave: 1): (0, 0), - Note(name: .c, octave: 2): (2, 1), + Note(name: .c, octave: 1): .init(correct: 4, wrong: 2), + Note(name: .d, octave: 1): .init(correct: 3, wrong: 0), + Note(name: .e, octave: 1): .init(correct: 0, wrong: 8), + Note(name: .f, octave: 1): .init(correct: 4, wrong: 1), + Note(name: .g, octave: 1): .init(correct: 0, wrong: 0), + Note(name: .c, octave: 2): .init(correct: 2, wrong: 1), ] ) let (nextState, effect) = loop.nextState( currentState: state, - action: .didLogRightAnswer(.success(session)) + action: .didLogRightAnswer(session) ) let startedSession = try #require(nextState.session) @@ -412,18 +263,7 @@ struct AppLoopTests { #expect(nextState.highlightedNote == nil) #expect(nextState.isInteractionEnabled == true) - let task = Task { - try await effect?.run() - } - - let action = try await task.value - - await #expect(practiceManager.isMoveToNextQuestionCalled == true) - - guard case .didLoadQuestion = action else { - Issue.record("expected next action to be `didLoadQuestion` after `didStartSession`") - return - } + #expect(effect == .loadNextQuestion) } @Test @@ -435,17 +275,17 @@ struct AppLoopTests { let session = Session( timestamp: 1000, score: [ - Note(name: .c, octave: 1): (4, 2), - Note(name: .d, octave: 1): (3, 0), - Note(name: .e, octave: 1): (0, 8), - Note(name: .f, octave: 1): (4, 1), - Note(name: .g, octave: 1): (0, 0), - Note(name: .c, octave: 2): (2, 1), + Note(name: .c, octave: 1): .init(correct: 4, wrong: 2), + Note(name: .d, octave: 1): .init(correct: 3, wrong: 0), + Note(name: .e, octave: 1): .init(correct: 0, wrong: 8), + Note(name: .f, octave: 1): .init(correct: 4, wrong: 1), + Note(name: .g, octave: 1): .init(correct: 0, wrong: 0), + Note(name: .c, octave: 2): .init(correct: 2, wrong: 1), ] ) let (nextState, effect) = loop.nextState( currentState: state, - action: .didLogWrongAnswer(.success(session)) + action: .didLogWrongAnswer(session) ) let startedSession = try #require(nextState.session) @@ -467,52 +307,21 @@ struct AppLoopTests { #expect(effect == nil) } - @Test - func didStartSessionFails() async throws { - let environment = makeAppEnvironment() - let state = AppState() - let loop = AppLoop(environment: environment, initialState: state) - - let (nextState, effect) = loop.nextState( - currentState: state, - action: .didStartSession(.failure(MockError.error)) - ) - - #expect(nextState.isLoading == false) - #expect(nextState.hasError == true) - #expect(nextState.error as? MockError == MockError.error) - - #expect(effect == nil) - } - @Test func didPressRepeatQuestionButton() async throws { - let notePlayer = MockNotePlayer() - let environment = makeAppEnvironment(notePlayer: notePlayer) - let state = AppState(isPracticing: true, level: makeLevel(id: 1), question: makeQuestion()) + let level = makeLevel(id: 1) + let question = makeQuestion() + let environment = makeAppEnvironment() + let state = AppState(isPracticing: true, level: level, question: question) let loop = AppLoop(environment: environment, initialState: state) - let (nextState, effect) = loop.nextState( currentState: state, action: .didPressRepeatQuestionButton ) #expect(nextState.isInteractionEnabled == false) - - let task = Task { - try await effect?.run() - } - - let action = try await task.value - - await #expect(notePlayer.isPlayNoteCalled == true) - await #expect(notePlayer.isPlayCadenceCalled == true) - - guard case .didPlayCadence = action else { - Issue.record("expected next action to be `didPlayCadence` after `didPressRepeatButton`") - return - } + #expect(effect == .repeatQuestion(level, question)) } @Test @@ -533,8 +342,7 @@ struct AppLoopTests { @Test func didPressNoteWhenNotPracticing() async throws { - let notePlayer = MockNotePlayer() - let environment = makeAppEnvironment(notePlayer: notePlayer) + let environment = makeAppEnvironment() let state = AppState(isPracticing: false) let loop = AppLoop(environment: environment, initialState: state) let note = Note(name: .d, octave: 1) @@ -547,17 +355,7 @@ struct AppLoopTests { let highlightedNote = try #require(nextState.highlightedNote) #expect(highlightedNote == (note, .amber)) - - let task = Task { - try await effect?.run() - } - - let action = try await task.value - - await #expect(notePlayer.isPlayNoteCalled == true) - await #expect(notePlayer.isPlayCadenceCalled == false) - - #expect(action == nil) + #expect(effect == .playNote(note)) } @Test @@ -581,30 +379,19 @@ struct AppLoopTests { #expect(highlightedNote == (note, .systemGreen)) #expect(nextState.answer == note) - - let task = Task { - try await effect?.run() - } - - let action = try await task.value - - guard case .didPlayNoteInResolution = action else { - Issue.record("expected next action to be `didPlayNoteInResolution` after `right answer` but was \(String(describing: action))") - return - } + #expect(effect == .playNoteInResolution(nil)) } @Test func didPressNoteWhenPracticingAndNoteIsWrong() async throws { - let notePlayer = MockNotePlayer() - let practiceManager = MockPracticeManager() - let environment = makeAppEnvironment(notePlayer: notePlayer, practiceManager: practiceManager) + let environment = makeAppEnvironment() let note = Note(name: .d, octave: 1) + let question = makeQuestion(answer: note, resolution: [note]) let wrongNote = Note(name: .c, octave: 2) let state = AppState( isPracticing: true, level: makeLevel(id: 42), - question: makeQuestion(answer: note, resolution: [note]) + question: question ) let loop = AppLoop(environment: environment, initialState: state) @@ -618,20 +405,7 @@ struct AppLoopTests { #expect(highlightedNote == (wrongNote, .systemRed)) #expect(nextState.answer == wrongNote) - - let task = Task { - try await effect?.run() - } - - let action = try await task.value - - await #expect(notePlayer.isPlayNoteCalled == true) - await #expect(practiceManager.isLogWrongAnswerCalled == true) - - guard case .didLogWrongAnswer = action else { - Issue.record("expected next action to be `didPlayNoteInResolution` after `right answer` but was \(String(describing: action))") - return - } + #expect(effect == .logWrongAnswer(wrongNote, question)) } @Test @@ -680,8 +454,7 @@ struct AppLoopTests { @Test func didSelectNotes() async throws { - let practiceManager = MockPracticeManager() - let environment = makeAppEnvironment(practiceManager: practiceManager) + let environment = makeAppEnvironment() let level = makeLevel(id: 42) let state = AppState(level: level) let notes = [ @@ -695,28 +468,12 @@ struct AppLoopTests { action: .didSelectNotes(notes) ) - let task = Task { - try await effect?.run() - } - - let action = try await task.value - - await #expect(practiceManager.isUseTemporaryLevelCalled == true) - - let temporaryLevel = await practiceManager.getTemporaryLevel() - - #expect(temporaryLevel?.notes ?? [] == notes) - - guard case .didLoadLevel = action else { - Issue.record("expected next action to be `didLoadLevel` after `didSelectNotes`") - return - } + #expect(effect == .loadLevel(level.withNotes(notes))) } @Test func didSelectNotesDoesNotUpdateLevelIfNotesHaveNotChanged() async throws { - let practiceManager = MockPracticeManager() - let environment = makeAppEnvironment(practiceManager: practiceManager) + let environment = makeAppEnvironment() let notes = [ Note(name: .d, octave: 1), Note(name: .eFlat, octave: 2), @@ -730,33 +487,20 @@ struct AppLoopTests { action: .didSelectNotes(notes) ) - let task = Task { - try await effect?.run() - } - - let action = try await task.value - - let temporaryLevel = await practiceManager.getTemporaryLevel() - - #expect(temporaryLevel?.isCustom == false) - - guard case .didLoadLevel = action else { - Issue.record("expected next action to be `didLoadLevel` after `didSelectNotes`") - return - } + #expect(effect == nil) } @Test - func didLoadQuestionSuccessfully() async throws { - let notePlayer = MockNotePlayer() - let environment = makeAppEnvironment(notePlayer: notePlayer) + func didLoadQuestion() async throws { + let environment = makeAppEnvironment() + let level = makeLevel(id: 42) let question = makeQuestion(answer: Note(name: .c, octave: 1)) - let state = AppState(isLoading: true, level: makeLevel(id: 42)) + let state = AppState(isLoading: true, level: level) let loop = AppLoop(environment: environment, initialState: state) let (nextState, effect) = loop.nextState( currentState: state, - action: .didLoadQuestion(.success(question)) + action: .didLoadQuestion(question) ) #expect(nextState.isLoading == false) @@ -765,31 +509,18 @@ struct AppLoopTests { #expect(nextState.question?.id ?? UUID() == question.id) #expect(nextState.highlightedNote == nil) #expect(nextState.isInteractionEnabled == false) - - let task = Task { - try await effect?.run() - } - - let action = try await task.value - - await #expect(notePlayer.isPlayCadenceCalled == true) - await #expect(notePlayer.isPlayNoteCalled == true) - - guard case .didPlayCadence = action else { - Issue.record("expected next action to be `didPlayCadence` after `didLoadQuestion`") - return - } + #expect(effect == .playCadence(level, question)) } @Test - func didPlayCadenceSuccessfully() async throws { + func didPlayCadence() async throws { let environment = makeAppEnvironment() let state = AppState(isInteractionEnabled: false) let loop = AppLoop(environment: environment, initialState: state) let (nextState, effect) = loop.nextState( currentState: state, - action: .didPlayCadence(.success(())) + action: .didPlayCadence ) #expect(nextState.isLoading == false) @@ -801,11 +532,10 @@ struct AppLoopTests { @Test func didPlayNoteInResolution() async throws { - let notePlayer = MockNotePlayer() - let environment = makeAppEnvironment(notePlayer: notePlayer) + let environment = makeAppEnvironment() let resolution: Resolution = [ Note(name: .d, octave: 1), - Note(name: .c, octave: 1) + Note(name: .c, octave: 1), ] let question = makeQuestion(answer: Note(name: .d, octave: 1), resolution: resolution) let state = AppState( @@ -818,39 +548,19 @@ struct AppLoopTests { let (nextState, effect) = loop.nextState( currentState: state, - action: .didPlayNoteInResolution(.success(())) + action: .didPlayNoteInResolution ) let highlightedNote = try #require(nextState.highlightedNote) #expect(highlightedNote == (Note(name: .d, octave: 1), .systemGreen)) #expect(nextState.currentlyPlayingResolution == [Note(name: .c, octave: 1)]) - - let task = Task { - try await effect?.run() - } - - let action = try await task.value - - await #expect(notePlayer.isPlayNoteCalled == true) - await #expect(notePlayer.isPlayCadenceCalled == false) - - let playedNote = await notePlayer.playedNote - - #expect(playedNote?.name == .d) - #expect(playedNote?.octave == 1) - - guard case .didPlayNoteInResolution = action else { - Issue.record("expected next action to be `didPlayNoteInResolution`") - return - } + #expect(effect == .playNoteInResolution(Note(name: .d, octave: 1))) } @Test func didPlayLastNoteInResolution() async throws { - let notePlayer = MockNotePlayer() - let practiceManager = MockPracticeManager() - let environment = makeAppEnvironment(notePlayer: notePlayer, practiceManager: practiceManager) + let environment = makeAppEnvironment() let resolution: Resolution = [] let question = makeQuestion(answer: Note(name: .d, octave: 1), resolution: resolution) let state = AppState( @@ -863,116 +573,12 @@ struct AppLoopTests { let (nextState, effect) = loop.nextState( currentState: state, - action: .didPlayNoteInResolution(.success(())) + action: .didPlayNoteInResolution ) #expect(nextState.highlightedNote == nil) #expect(nextState.currentlyPlayingResolution.isEmpty == true) - - let task = Task { - try await effect?.run() - } - - let action = try await task.value - - await #expect(practiceManager.isLogCorrectAnswerCalled == true) - await #expect(notePlayer.isPlayNoteCalled == false) - await #expect(notePlayer.isPlayCadenceCalled == false) - - guard case .didLogRightAnswer = action else { - Issue.record("expected next action to be `didLogRightAnswer` after resolution") - return - } - } - - @Test - func didLoadQuestionFails() async throws { - let environment = makeAppEnvironment() - let state = AppState(isLoading: true) - let loop = AppLoop(environment: environment, initialState: state) - - let (nextState, effect) = loop.nextState( - currentState: state, - action: .didLoadQuestion(.failure(MockError.error)) - ) - - #expect(nextState.isLoading == false) - #expect(nextState.hasError == true) - #expect(nextState.error as? MockError == MockError.error) - - #expect(effect == nil) - } - - @Test - func didLogRightAnswerFails() async throws { - let environment = makeAppEnvironment() - let state = AppState(isLoading: true) - let loop = AppLoop(environment: environment, initialState: state) - - let (nextState, effect) = loop.nextState( - currentState: state, - action: .didLogRightAnswer(.failure(MockError.error)) - ) - - #expect(nextState.isLoading == false) - #expect(nextState.hasError == true) - #expect(nextState.error as? MockError == MockError.error) - - #expect(effect == nil) - } - - @Test - func didLogWrongAnswerFails() async throws { - let environment = makeAppEnvironment() - let state = AppState(isLoading: true) - let loop = AppLoop(environment: environment, initialState: state) - - let (nextState, effect) = loop.nextState( - currentState: state, - action: .didLogWrongAnswer(.failure(MockError.error)) - ) - - #expect(nextState.isLoading == false) - #expect(nextState.hasError == true) - #expect(nextState.error as? MockError == MockError.error) - - #expect(effect == nil) - } - - @Test - func didPlayCadenceFails() async throws { - let environment = makeAppEnvironment() - let state = AppState(isLoading: true) - let loop = AppLoop(environment: environment, initialState: state) - - let (nextState, effect) = loop.nextState( - currentState: state, - action: .didPlayCadence(.failure(MockError.error)) - ) - - #expect(nextState.isLoading == false) - #expect(nextState.hasError == true) - #expect(nextState.error as? MockError == MockError.error) - - #expect(effect == nil) - } - - @Test - func didPlayNoteInResolutionFails() async throws { - let environment = makeAppEnvironment() - let state = AppState() - let loop = AppLoop(environment: environment, initialState: state) - - let (nextState, effect) = loop.nextState( - currentState: state, - action: .didPlayNoteInResolution(.failure(MockError.error)) - ) - - #expect(nextState.isLoading == false) - #expect(nextState.hasError == true) - #expect(nextState.error as? MockError == MockError.error) - - #expect(effect == nil) + #expect(effect == .logRightAnswer(question.answer, question)) } @Test @@ -992,7 +598,7 @@ struct AppLoopTests { let (nextState, effect) = loop.nextState( currentState: state, - action: .didLoadLevel(.success(makeLevel(id: 42))) + action: .didLoadLevel(makeLevel(id: 42)) ) #expect(nextState.isLoading == false) @@ -1027,7 +633,7 @@ struct AppLoopTests { let (nextState, effect) = loop.nextState( currentState: state, - action: .didLoadLevel(.success(makeLevel(id: 42))) + action: .didLoadLevel(makeLevel(id: 42)) ) #expect(nextState.isLoading == false) @@ -1098,221 +704,21 @@ struct AppLoopTests { #expect(effect == nil) } - // MARK: - Private Helpers - - private func makeAppEnvironment( - notePlayer: NotePlayer = MockNotePlayer(), - practiceManager: PracticeManager = MockPracticeManager(), - tipProvider: TipProvider = MockTipProvider(), - preferences: Preferences = MockPreferences(), - logger: Logger = Logger() - ) -> AppEnvironment { - AppEnvironment( - notePlayer: notePlayer, - practiceManager: practiceManager, - tipProvider: tipProvider, - preferences: preferences, - logger: logger - ) - } -} - -// MARK: - Mocks - -// MARK: - MockNotePlayer - -private actor MockNotePlayer: NotePlayer { - var isPrepareToPlayCalled = false - var isPlayNoteCalled = false - var isPlayCadenceCalled = false - var playedNote: Note? - - func prepareToPlay() async throws { - isPrepareToPlayCalled = true - } - - func playNote(_ note: Note) async throws { - isPlayNoteCalled = true - playedNote = note - } - - func playCadence(_ cadence: Cadence) async throws { - isPlayCadenceCalled = true - } - - func getPlayedNote() async -> Note? { - playedNote - } -} - -// MARK: - MockPracticeManager - -private actor MockPracticeManager: PracticeManager { - var isPrepareToPracticeCalled = false - var isMoveToFirstLevelCalled = false - var isMoveToNextLevelCalled = false - var isMoveToPreviousLevelCalled = false - var isMoveToRandomLevelCalled = false - var isStartSessionCalled = false - var isStopSessionCalled = false - var isMoveToNextQuestionCalled = false - var isLogCorrectAnswerCalled = false - var isLogWrongAnswerCalled = false - var isUseTemporaryLevelCalled = false - var temporaryLevel: Level? = nil - var cursor = 0 - - func prepareToPractice() async throws { - isPrepareToPracticeCalled = true - } - - func moveToPreviousLevel() async throws -> Level { - isMoveToPreviousLevelCalled = true - cursor -= 1 - return makeLevel(id: cursor) - } - - func moveToNextLevel() async throws -> Level { - isMoveToNextLevelCalled = true - cursor += 1 - return makeLevel(id: cursor) - } - - func moveToRandomLevel() async throws -> Level { - isMoveToRandomLevelCalled = true - cursor = (1...10).randomElement() ?? 1 - return makeLevel(id: cursor) - } - - func moveToFirstLevel() async throws -> Level { - isMoveToFirstLevelCalled = true - cursor = 1 - return makeLevel(id: cursor) - } - - func startSession() async throws -> Session { - isStartSessionCalled = true - return makeSession() - } - - func stopCurrentSession() async throws -> Level { - isStopSessionCalled = true - return makeLevel(id: cursor) - } - - func moveToNextQuestion() async throws -> Question { - isMoveToNextQuestionCalled = true - return makeQuestion() - } - - func logCorrectAnswer( - _ note: Bemol.Note, - for question: Bemol.Question - ) async throws -> Session { - isLogCorrectAnswerCalled = true - return makeSession() - } - - func logWrongAnswer( - _ note: Note, - for question: Question - ) async throws -> Session { - isLogWrongAnswerCalled = true - return makeSession() - } - - func setCurrentLevel(_ level: Level) async throws -> Level { - isUseTemporaryLevelCalled = true - temporaryLevel = level - return level - } - - func getTemporaryLevel() async -> Level? { - temporaryLevel - } -} - -// MARK: - MockTipProvider - -private final class MockTipProvider: TipProvider { - let tips: [Tip] - var index = -1 - - init(tips: [Tip] = []) { - self.tips = tips - } - - func nextTip() -> Tip? { - if index + 1 < tips.count { - index += 1 - return tips[index] - } - - return nil - } -} + @Test + func errorOccurred() async throws { + let environment = makeAppEnvironment() + let state = AppState(isLoading: true) + let loop = AppLoop(environment: environment, initialState: state) -// MARK: - MockPreferences + let (nextState, effect) = loop.nextState( + currentState: state, + action: .errorOccurred(MockError.error) + ) -private final class MockPreferences: Preferences { - var values: [String: Any] = [:] + #expect(nextState.isLoading == false) + #expect(nextState.hasError == true) + #expect(nextState.error as? MockError == MockError.error) - func value(for key: PreferenceKey) -> Int? { - values[key.rawValue] as? Int - } - - func setValue(_ value: Int, for key: PreferenceKey) { - values[key.rawValue] = value - } - - func value(for key: PreferenceKey) -> Bool { - (values[key.rawValue] as? Bool) ?? false - } - - func setValue(_ value: Bool, for key: PreferenceKey) { - values[key.rawValue] = value + #expect(effect == nil) } } - -// MARK: - MockError - -private enum MockError: Error { - case error -} - -// MARK: - - -fileprivate func makeLevel( - id: Int, - key: NoteName = .c, - isMajor: Bool = false, - isChromatic: Bool = false, - notes: [Note] = [], - cadence: Cadence = Cadence(voices: [], roots: [], movement: []), - spansMultipleOctaves: Bool = false, - range: NoteRange = .firstHalfOfOctave, - sessions: [Session] = [] -) -> Level { - Level( - id: id, - key: key, - isMajor: isMajor, - isChromatic: isChromatic, - notes: notes, - cadence: cadence, - spansMultipleOctaves: spansMultipleOctaves, - range: range, - sessions: sessions - ) -} - -fileprivate func makeSession() -> Session { - Session(timestamp: Date.now.timeIntervalSince1970, score: [:]) -} - -fileprivate func makeQuestion( - answer: Note = Note(name: .dFlat, octave: 1), - resolution: Resolution = [] -) -> Question { - Question(answer: answer, resolution: resolution) -} diff --git a/Tests/Factories.swift b/Tests/Factories.swift new file mode 100644 index 0000000..cda8a18 --- /dev/null +++ b/Tests/Factories.swift @@ -0,0 +1,80 @@ +/// +/// Factories.swift +/// Bemol +/// +/// Copyright 2026 Faiçal Tchirou +/// +/// Bemol is free software: you can redistribute it and/or modify it under the terms of +/// the GNU General Public License as published by the Free Software Foundation, either version 3 +/// of the License, or (at your option) any later version. +/// +/// Bemol is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +/// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +/// See the GNU General Public License for more details. +/// +/// You should have received a copy of the GNU General Public License along with Foobar. +/// If not, see . +/// + +import Foundation +import os + +@testable import Bemol + +@MainActor +func makeAppEnvironment( + notePlayer: (any NotePlayer)? = nil, + practiceManager: (any PracticeManager)? = nil, + tipProvider: (any TipProvider)? = nil, + preferences: (any Preferences)? = nil, + logger: Logger = Logger() +) -> AppEnvironment { + AppEnvironment( + notePlayer: notePlayer ?? MockNotePlayer(), + practiceManager: practiceManager ?? MockPracticeManager(), + tipProvider: tipProvider ?? MockTipProvider(), + preferences: preferences ?? MockPreferences(), + logger: logger + ) +} + +@MainActor +func makeLevel( + id: Int, + key: NoteName = .c, + isMajor: Bool = false, + isChromatic: Bool = false, + notes: [Note] = [], + cadence: Cadence = Cadence(voices: [], roots: [], movement: []), + spansMultipleOctaves: Bool = false, + range: NoteRange = .firstHalfOfOctave, + sessions: [Session] = [] +) -> Level { + Level( + id: id, + key: key, + isMajor: isMajor, + isChromatic: isChromatic, + notes: notes, + cadence: cadence, + spansMultipleOctaves: spansMultipleOctaves, + range: range, + sessions: sessions + ) +} + +@MainActor +func makeSession() -> Session { + Session(timestamp: Date.now.timeIntervalSince1970, score: [:]) +} + +@MainActor +func makeQuestion( + answer: Note? = nil, + resolution: Resolution = [] +) -> Question { + Question( + answer: answer ?? Note(name: .dFlat, octave: 1), + resolution: resolution + ) +} diff --git a/Tests/Mocks/MockError.swift b/Tests/Mocks/MockError.swift new file mode 100644 index 0000000..77bbb95 --- /dev/null +++ b/Tests/Mocks/MockError.swift @@ -0,0 +1,22 @@ +/// +/// MockError.swift +/// Bemol +/// +/// Copyright 2026 Faiçal Tchirou +/// +/// Bemol is free software: you can redistribute it and/or modify it under the terms of +/// the GNU General Public License as published by the Free Software Foundation, either version 3 +/// of the License, or (at your option) any later version. +/// +/// Bemol is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +/// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +/// See the GNU General Public License for more details. +/// +/// You should have received a copy of the GNU General Public License along with Foobar. +/// If not, see . + +import Foundation + +enum MockError: Error { + case error +} diff --git a/Tests/Mocks/MockNotePlayer.swift b/Tests/Mocks/MockNotePlayer.swift new file mode 100644 index 0000000..eae0489 --- /dev/null +++ b/Tests/Mocks/MockNotePlayer.swift @@ -0,0 +1,45 @@ +/// +/// MockNotePlayer.swift +/// Bemol +/// +/// Copyright 2026 Faiçal Tchirou +/// +/// Bemol is free software: you can redistribute it and/or modify it under the terms of +/// the GNU General Public License as published by the Free Software Foundation, either version 3 +/// of the License, or (at your option) any later version. +/// +/// Bemol is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +/// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +/// See the GNU General Public License for more details. +/// +/// You should have received a copy of the GNU General Public License along with Foobar. +/// If not, see . +/// + +import Foundation + +@testable import Bemol + +final class MockNotePlayer: NotePlayer { + var isPrepareToPlayCalled = false + var isPlayNoteCalled = false + var isPlayCadenceCalled = false + var playedNote: Note? + + func prepareToPlay() async throws { + isPrepareToPlayCalled = true + } + + func playNote(_ note: Note) async throws { + isPlayNoteCalled = true + playedNote = note + } + + func playCadence(_ cadence: Cadence) async throws { + isPlayCadenceCalled = true + } + + func getPlayedNote() async -> Note? { + playedNote + } +} diff --git a/Tests/Mocks/MockPracticeManager.swift b/Tests/Mocks/MockPracticeManager.swift new file mode 100644 index 0000000..0943a74 --- /dev/null +++ b/Tests/Mocks/MockPracticeManager.swift @@ -0,0 +1,105 @@ +/// +/// MockPracticeManager.swift +/// Bemol +/// +/// Copyright 2026 Faiçal Tchirou +/// +/// Bemol is free software: you can redistribute it and/or modify it under the terms of +/// the GNU General Public License as published by the Free Software Foundation, either version 3 +/// of the License, or (at your option) any later version. +/// +/// Bemol is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +/// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +/// See the GNU General Public License for more details. +/// +/// You should have received a copy of the GNU General Public License along with Foobar. +/// If not, see . + +import Foundation + +@testable import Bemol + +final class MockPracticeManager: PracticeManager { + var isPrepareToPracticeCalled = false + var isMoveToFirstLevelCalled = false + var isMoveToNextLevelCalled = false + var isMoveToPreviousLevelCalled = false + var isMoveToRandomLevelCalled = false + var isStartSessionCalled = false + var isStopSessionCalled = false + var isMoveToNextQuestionCalled = false + var isLogCorrectAnswerCalled = false + var isLogWrongAnswerCalled = false + var isUseTemporaryLevelCalled = false + var temporaryLevel: Level? = nil + var cursor = 0 + + func prepareToPractice() async throws { + isPrepareToPracticeCalled = true + } + + func moveToPreviousLevel() async throws -> Level { + isMoveToPreviousLevelCalled = true + cursor -= 1 + return makeLevel(id: cursor) + } + + func moveToNextLevel() async throws -> Level { + isMoveToNextLevelCalled = true + cursor += 1 + return makeLevel(id: cursor) + } + + func moveToRandomLevel() async throws -> Level { + isMoveToRandomLevelCalled = true + cursor = (1...10).randomElement() ?? 1 + return makeLevel(id: cursor) + } + + func moveToFirstLevel() async throws -> Level { + isMoveToFirstLevelCalled = true + cursor = 1 + return makeLevel(id: cursor) + } + + func startSession() async throws -> Session { + isStartSessionCalled = true + return makeSession() + } + + func stopCurrentSession() async throws -> Level { + isStopSessionCalled = true + return makeLevel(id: cursor) + } + + func moveToNextQuestion() async throws -> Question { + isMoveToNextQuestionCalled = true + return makeQuestion() + } + + func logCorrectAnswer( + _ note: Bemol.Note, + for question: Bemol.Question + ) async throws -> Session { + isLogCorrectAnswerCalled = true + return makeSession() + } + + func logWrongAnswer( + _ note: Note, + for question: Question + ) async throws -> Session { + isLogWrongAnswerCalled = true + return makeSession() + } + + func setCurrentLevel(_ level: Level) async throws -> Level { + isUseTemporaryLevelCalled = true + temporaryLevel = level + return level + } + + func getTemporaryLevel() async -> Level? { + temporaryLevel + } +} diff --git a/Tests/Mocks/MockPreferences.swift b/Tests/Mocks/MockPreferences.swift new file mode 100644 index 0000000..c3f3813 --- /dev/null +++ b/Tests/Mocks/MockPreferences.swift @@ -0,0 +1,40 @@ +/// +/// MockPreferences.swift +/// Bemol +/// +/// Copyright 2026 Faiçal Tchirou +/// +/// Bemol is free software: you can redistribute it and/or modify it under the terms of +/// the GNU General Public License as published by the Free Software Foundation, either version 3 +/// of the License, or (at your option) any later version. +/// +/// Bemol is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +/// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +/// See the GNU General Public License for more details. +/// +/// You should have received a copy of the GNU General Public License along with Foobar. +/// If not, see . + +import Foundation + +@testable import Bemol + +final class MockPreferences: Preferences { + var values: [String: Any] = [:] + + func value(for key: PreferenceKey) -> Int? { + values[key.rawValue] as? Int + } + + func setValue(_ value: Int, for key: PreferenceKey) { + values[key.rawValue] = value + } + + func value(for key: PreferenceKey) -> Bool { + (values[key.rawValue] as? Bool) ?? false + } + + func setValue(_ value: Bool, for key: PreferenceKey) { + values[key.rawValue] = value + } +} diff --git a/Tests/Mocks/MockTipProvider.swift b/Tests/Mocks/MockTipProvider.swift new file mode 100644 index 0000000..8185982 --- /dev/null +++ b/Tests/Mocks/MockTipProvider.swift @@ -0,0 +1,39 @@ +/// +/// MockTipProvider.swift +/// Bemol +/// +/// Copyright 2026 Faiçal Tchirou +/// +/// Bemol is free software: you can redistribute it and/or modify it under the terms of +/// the GNU General Public License as published by the Free Software Foundation, either version 3 +/// of the License, or (at your option) any later version. +/// +/// Bemol is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +/// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +/// See the GNU General Public License for more details. +/// +/// You should have received a copy of the GNU General Public License along with Foobar. +/// If not, see . +/// + +import Foundation + +@testable import Bemol + +final class MockTipProvider: TipProvider { + let tips: [Tip] + var index = -1 + + init(tips: [Tip] = []) { + self.tips = tips + } + + func nextTip() -> Tip? { + if index + 1 < tips.count { + index += 1 + return tips[index] + } + + return nil + } +} diff --git a/BemolTests/Models/LevelTests.swift b/Tests/Models/LevelTests.swift similarity index 87% rename from BemolTests/Models/LevelTests.swift rename to Tests/Models/LevelTests.swift index 3bcb18a..03b6b42 100644 --- a/BemolTests/Models/LevelTests.swift +++ b/Tests/Models/LevelTests.swift @@ -3,6 +3,7 @@ import Testing @testable import Bemol +@MainActor struct LevelTests { @Test func summary() async throws { @@ -27,12 +28,12 @@ struct LevelTests { Session( timestamp: 1000, score: [ - Note(name: .c, octave: 1): (4, 2), - Note(name: .d, octave: 1): (3, 0), - Note(name: .e, octave: 1): (0, 8), - Note(name: .f, octave: 1): (4, 1), - Note(name: .g, octave: 1): (0, 0), - Note(name: .c, octave: 2): (2, 1), + Note(name: .c, octave: 1): .init(correct: 4, wrong: 2), + Note(name: .d, octave: 1): .init(correct: 3, wrong: 0), + Note(name: .e, octave: 1): .init(correct: 0, wrong: 8), + Note(name: .f, octave: 1): .init(correct: 4, wrong: 1), + Note(name: .g, octave: 1): .init(correct: 0, wrong: 0), + Note(name: .c, octave: 2): .init(correct: 2, wrong: 1), ] ) ] @@ -73,24 +74,24 @@ struct LevelTests { Session( timestamp: 1000, score: [ - Note(name: .c, octave: 1): (4, 2), - Note(name: .e, octave: 1): (0, 8), + Note(name: .c, octave: 1): .init(correct: 4, wrong: 2), + Note(name: .e, octave: 1): .init(correct: 0, wrong: 8), ] ), Session( timestamp: 1100, score: [ - Note(name: .d, octave: 1): (3, 0), - Note(name: .f, octave: 1): (4, 1), + Note(name: .d, octave: 1): .init(correct: 3, wrong: 0), + Note(name: .f, octave: 1): .init(correct: 4, wrong: 1), ] ), Session( timestamp: 1200, score: [ - Note(name: .g, octave: 1): (0, 0), - Note(name: .c, octave: 2): (2, 1), + Note(name: .g, octave: 1): .init(correct: 0, wrong: 0), + Note(name: .c, octave: 2): .init(correct: 2, wrong: 1), ] - ) + ), ] ) @@ -127,8 +128,8 @@ struct LevelTests { Session( timestamp: 1000, score: [ - Note(name: .c, octave: 1): (5, 0), - Note(name: .d, octave: 1): (6, 0), + Note(name: .c, octave: 1): .init(correct: 5, wrong: 0), + Note(name: .d, octave: 1): .init(correct: 6, wrong: 0), ] ) ] @@ -165,8 +166,8 @@ struct LevelTests { Session( timestamp: 1000, score: [ - Note(name: .c, octave: 1): (5, 0), - Note(name: .d, octave: 1): (6, 0), + Note(name: .c, octave: 1): .init(correct: 5, wrong: 0), + Note(name: .d, octave: 1): .init(correct: 6, wrong: 0), ] ) ] @@ -200,14 +201,14 @@ struct LevelTests { Session( timestamp: 1000, score: [ - Note(name: .c, octave: 1): (5, 0), - Note(name: .d, octave: 1): (6, 0), + Note(name: .c, octave: 1): .init(correct: 5, wrong: 0), + Note(name: .d, octave: 1): .init(correct: 6, wrong: 0), ] ) ] ) let newLevel = level.withSessions([ - Session(timestamp: 1200, score: [Note(name: .bFlat, octave: 2): (1, 0)]) + Session(timestamp: 1200, score: [Note(name: .bFlat, octave: 2): .init(correct: 1, wrong: 0)]) ]) #expect(newLevel.id == level.id) @@ -223,7 +224,7 @@ struct LevelTests { let session = try #require(newLevel.sessions.first) #expect(session.timestamp == 1200.0) - #expect(session.score[Note(name: .bFlat, octave: 2)] ?? (0, 0) == (1, 0)) + #expect(session.score[Note(name: .bFlat, octave: 2)] ?? .zero == .init(correct: 1, wrong: 0)) #expect(session.score[Note(name: .c, octave: 1)] == nil) #expect(session.score[Note(name: .d, octave: 1)] == nil) } diff --git a/BemolTests/Models/SessionTests.swift b/Tests/Models/SessionTests.swift similarity index 69% rename from BemolTests/Models/SessionTests.swift rename to Tests/Models/SessionTests.swift index 48816e3..aada050 100644 --- a/BemolTests/Models/SessionTests.swift +++ b/Tests/Models/SessionTests.swift @@ -3,18 +3,19 @@ import Testing @testable import Bemol +@MainActor struct SessionTests { @Test func summary() async throws { let session = Session( timestamp: 1000, score: [ - Note(name: .c, octave: 1): (4, 2), - Note(name: .d, octave: 1): (3, 0), - Note(name: .e, octave: 1): (0, 8), - Note(name: .f, octave: 1): (4, 1), - Note(name: .g, octave: 1): (0, 0), - Note(name: .c, octave: 2): (2, 1), + Note(name: .c, octave: 1): .init(correct: 4, wrong: 2), + Note(name: .d, octave: 1): .init(correct: 3, wrong: 0), + Note(name: .e, octave: 1): .init(correct: 0, wrong: 8), + Note(name: .f, octave: 1): .init(correct: 4, wrong: 1), + Note(name: .g, octave: 1): .init(correct: 0, wrong: 0), + Note(name: .c, octave: 2): .init(correct: 2, wrong: 1), ] ) diff --git a/BemolTests/Services/CyclicPracticeManagerTests.swift b/Tests/Services/CyclicPracticeManagerTests.swift similarity index 78% rename from BemolTests/Services/CyclicPracticeManagerTests.swift rename to Tests/Services/CyclicPracticeManagerTests.swift index d500bca..8c52eac 100644 --- a/BemolTests/Services/CyclicPracticeManagerTests.swift +++ b/Tests/Services/CyclicPracticeManagerTests.swift @@ -21,6 +21,7 @@ import Testing @testable import Bemol +@MainActor struct CyclicPracticeManagerTests { @Test func logCorrectAnswer() async throws { @@ -37,7 +38,7 @@ struct CyclicPracticeManagerTests { let updatedSession = try await manager.logCorrectAnswer(note, for: question) #expect(updatedSession.score.count == 1) - #expect(updatedSession.score[note] ?? (0, 0) == (1, 0)) + #expect(updatedSession.score[note] ?? .zero == .init(correct: 1, wrong: 0)) } @Test @@ -59,7 +60,7 @@ struct CyclicPracticeManagerTests { let updatedSession = try await manager.logCorrectAnswer(note, for: question3) #expect(updatedSession.score.count == 1) - #expect(updatedSession.score[note] ?? (0, 0) == (3, 0)) + #expect(updatedSession.score[note] ?? .zero == .init(correct: 3, wrong: 0)) } @Test @@ -79,7 +80,7 @@ struct CyclicPracticeManagerTests { let updatedSession = try await manager.logCorrectAnswer(note, for: question) #expect(updatedSession.score.count == 1) - #expect(updatedSession.score[note] ?? (0, 0) == (1, 0)) + #expect(updatedSession.score[note] ?? .zero == .init(correct: 1, wrong: 0)) } @Test @@ -97,7 +98,7 @@ struct CyclicPracticeManagerTests { let updatedSession = try await manager.logWrongAnswer(note, for: question) #expect(updatedSession.score.count == 1) - #expect(updatedSession.score[note] ?? (0, 0) == (0, 1)) + #expect(updatedSession.score[note] ?? .zero == .init(correct: 0, wrong: 1)) } @Test @@ -119,7 +120,7 @@ struct CyclicPracticeManagerTests { let updatedSession = try await manager.logWrongAnswer(note, for: question3) #expect(updatedSession.score.count == 1) - #expect(updatedSession.score[note] ?? (0, 0) == (0, 3)) + #expect(updatedSession.score[note] ?? .zero == .init(correct: 0, wrong: 3)) } @Test @@ -139,7 +140,7 @@ struct CyclicPracticeManagerTests { let updatedSession = try await manager.logWrongAnswer(note, for: question) #expect(updatedSession.score.count == 1) - #expect(updatedSession.score[note] ?? (0, 0) == (0, 1)) + #expect(updatedSession.score[note] ?? .zero == .init(correct: 0, wrong: 1)) } @Test @@ -165,7 +166,7 @@ struct CyclicPracticeManagerTests { let updatedSession = try await manager.logCorrectAnswer(note, for: question5) #expect(updatedSession.score.count == 1) - #expect(updatedSession.score[note] ?? (0, 0) == (3, 2)) + #expect(updatedSession.score[note] ?? .zero == .init(correct: 3, wrong: 2)) } @Test @@ -192,8 +193,8 @@ struct CyclicPracticeManagerTests { let updatedSession = try await manager.logCorrectAnswer(note2, for: question5) #expect(updatedSession.score.count == 2) - #expect(updatedSession.score[note1] ?? (0, 0) == (1, 1)) - #expect(updatedSession.score[note2] ?? (0, 0) == (2, 1)) + #expect(updatedSession.score[note1] ?? .zero == .init(correct: 1, wrong: 1)) + #expect(updatedSession.score[note2] ?? .zero == .init(correct: 2, wrong: 1)) } @Test @@ -220,7 +221,7 @@ struct CyclicPracticeManagerTests { let session = try #require(level.sessions.first) #expect(session.score.count == 1) - #expect(session.score[note] ?? (0, 0) == (1, 0)) + #expect(session.score[note] ?? .zero == .init(correct: 1, wrong: 0)) } @Test @@ -250,7 +251,7 @@ struct CyclicPracticeManagerTests { let session = try #require(level.sessions.first) #expect(session.score.count == 1) - #expect(session.score[note] ?? (0, 0) == (1, 0)) + #expect(session.score[note] ?? .zero == .init(correct: 1, wrong: 0)) // Session #2 @@ -264,7 +265,7 @@ struct CyclicPracticeManagerTests { let session2 = try #require(level2.sessions.last) #expect(session2.score.count == 1) - #expect(session2.score[note] ?? (0, 0) == (0, 1)) + #expect(session2.score[note] ?? .zero == .init(correct: 0, wrong: 1)) } @Test @@ -287,12 +288,12 @@ struct CyclicPracticeManagerTests { let _ = try await manager.stopCurrentSession() - await #expect(storage.isSaveSessionCalled == true) + #expect(storage.isSaveSessionCalled == true) let savedSession = try #require(await storage.getSavedSession()) #expect(savedSession.score.count == 1) - #expect(savedSession.score[note] ?? (0, 0) == (1, 0)) + #expect(savedSession.score[note] ?? .zero == .init(correct: 1, wrong: 0)) } @Test @@ -317,8 +318,8 @@ struct CyclicPracticeManagerTests { let _ = try await manager.stopCurrentSession() - await #expect(storage.isSaveSessionCalled == false) - await #expect(storage.savedSession == nil) + #expect(storage.isSaveSessionCalled == false) + #expect(storage.savedSession == nil) } @Test @@ -337,8 +338,8 @@ struct CyclicPracticeManagerTests { let _ = try await manager.stopCurrentSession() - await #expect(storage.isSaveSessionCalled == false) - await #expect(storage.savedSession == nil) + #expect(storage.isSaveSessionCalled == false) + #expect(storage.savedSession == nil) } @Test @@ -370,7 +371,6 @@ struct CyclicPracticeManagerTests { let preferences = MockPreferences() preferences.setValue(1, for: .latestPracticeCursor) - let manager = CyclicPracticeManager( storage: MockSessionStorage(), levelGenerator: MockLevelGenerator(), @@ -427,29 +427,29 @@ struct CyclicPracticeManagerTests { level = try await manager.moveToNextLevel() -// #expect(level.key == key) -// #expect(level.isMajor == isMajor) -// #expect(level.spansMultipleOctaves == true) -// #expect(level.isChromatic == false) -// #expect(level.range == .firstHalfOfOctave) -// -// level = try await manager.moveToNextLevel() -// -// #expect(level.key == key) -// #expect(level.isMajor == isMajor) -// #expect(level.spansMultipleOctaves == true) -// #expect(level.isChromatic == false) -// #expect(level.range == .secondHalfOfOctave) -// -// level = try await manager.moveToNextLevel() -// -// #expect(level.key == key) -// #expect(level.isMajor == isMajor) -// #expect(level.spansMultipleOctaves == true) -// #expect(level.isChromatic == false) -// #expect(level.range == .entireOctave) -// -// level = try await manager.moveToNextLevel() + // #expect(level.key == key) + // #expect(level.isMajor == isMajor) + // #expect(level.spansMultipleOctaves == true) + // #expect(level.isChromatic == false) + // #expect(level.range == .firstHalfOfOctave) + // + // level = try await manager.moveToNextLevel() + // + // #expect(level.key == key) + // #expect(level.isMajor == isMajor) + // #expect(level.spansMultipleOctaves == true) + // #expect(level.isChromatic == false) + // #expect(level.range == .secondHalfOfOctave) + // + // level = try await manager.moveToNextLevel() + // + // #expect(level.key == key) + // #expect(level.isMajor == isMajor) + // #expect(level.spansMultipleOctaves == true) + // #expect(level.isChromatic == false) + // #expect(level.range == .entireOctave) + // + // level = try await manager.moveToNextLevel() #expect(level.key == key) #expect(level.isMajor == isMajor) @@ -473,29 +473,29 @@ struct CyclicPracticeManagerTests { #expect(level.isChromatic == true) #expect(level.range == .entireOctave) -// level = try await manager.moveToNextLevel() -// -// #expect(level.key == key) -// #expect(level.isMajor == isMajor) -// #expect(level.spansMultipleOctaves == true) -// #expect(level.isChromatic == true) -// #expect(level.range == .firstHalfOfOctave) -// -// level = try await manager.moveToNextLevel() -// -// #expect(level.key == key) -// #expect(level.isMajor == isMajor) -// #expect(level.spansMultipleOctaves == true) -// #expect(level.isChromatic == true) -// #expect(level.range == .secondHalfOfOctave) -// -// level = try await manager.moveToNextLevel() -// -// #expect(level.key == key) -// #expect(level.isMajor == isMajor) -// #expect(level.spansMultipleOctaves == true) -// #expect(level.isChromatic == true) -// #expect(level.range == .entireOctave) + // level = try await manager.moveToNextLevel() + // + // #expect(level.key == key) + // #expect(level.isMajor == isMajor) + // #expect(level.spansMultipleOctaves == true) + // #expect(level.isChromatic == true) + // #expect(level.range == .firstHalfOfOctave) + // + // level = try await manager.moveToNextLevel() + // + // #expect(level.key == key) + // #expect(level.isMajor == isMajor) + // #expect(level.spansMultipleOctaves == true) + // #expect(level.isChromatic == true) + // #expect(level.range == .secondHalfOfOctave) + // + // level = try await manager.moveToNextLevel() + // + // #expect(level.key == key) + // #expect(level.isMajor == isMajor) + // #expect(level.spansMultipleOctaves == true) + // #expect(level.isChromatic == true) + // #expect(level.range == .entireOctave) } } @@ -523,29 +523,29 @@ struct CyclicPracticeManagerTests { for isMajor in [false, true] { var level = try await manager.moveToPreviousLevel() -// #expect(level.key == key) -// #expect(level.isMajor == isMajor) -// #expect(level.spansMultipleOctaves == true) -// #expect(level.isChromatic == true) -// #expect(level.range == .entireOctave) -// -// level = try await manager.moveToPreviousLevel() -// -// #expect(level.key == key) -// #expect(level.isMajor == isMajor) -// #expect(level.spansMultipleOctaves == true) -// #expect(level.isChromatic == true) -// #expect(level.range == .secondHalfOfOctave) -// -// level = try await manager.moveToPreviousLevel() -// -// #expect(level.key == key) -// #expect(level.isMajor == isMajor) -// #expect(level.spansMultipleOctaves == true) -// #expect(level.isChromatic == true) -// #expect(level.range == .firstHalfOfOctave) -// -// level = try await manager.moveToPreviousLevel() + // #expect(level.key == key) + // #expect(level.isMajor == isMajor) + // #expect(level.spansMultipleOctaves == true) + // #expect(level.isChromatic == true) + // #expect(level.range == .entireOctave) + // + // level = try await manager.moveToPreviousLevel() + // + // #expect(level.key == key) + // #expect(level.isMajor == isMajor) + // #expect(level.spansMultipleOctaves == true) + // #expect(level.isChromatic == true) + // #expect(level.range == .secondHalfOfOctave) + // + // level = try await manager.moveToPreviousLevel() + // + // #expect(level.key == key) + // #expect(level.isMajor == isMajor) + // #expect(level.spansMultipleOctaves == true) + // #expect(level.isChromatic == true) + // #expect(level.range == .firstHalfOfOctave) + // + // level = try await manager.moveToPreviousLevel() #expect(level.key == key) #expect(level.isMajor == isMajor) @@ -571,29 +571,29 @@ struct CyclicPracticeManagerTests { level = try await manager.moveToPreviousLevel() -// #expect(level.key == key) -// #expect(level.isMajor == isMajor) -// #expect(level.spansMultipleOctaves == true) -// #expect(level.isChromatic == false) -// #expect(level.range == .entireOctave) -// -// level = try await manager.moveToPreviousLevel() -// -// #expect(level.key == key) -// #expect(level.isMajor == isMajor) -// #expect(level.spansMultipleOctaves == true) -// #expect(level.isChromatic == false) -// #expect(level.range == .secondHalfOfOctave) -// -// level = try await manager.moveToPreviousLevel() -// -// #expect(level.key == key) -// #expect(level.isMajor == isMajor) -// #expect(level.spansMultipleOctaves == true) -// #expect(level.isChromatic == false) -// #expect(level.range == .firstHalfOfOctave) -// -// level = try await manager.moveToPreviousLevel() + // #expect(level.key == key) + // #expect(level.isMajor == isMajor) + // #expect(level.spansMultipleOctaves == true) + // #expect(level.isChromatic == false) + // #expect(level.range == .entireOctave) + // + // level = try await manager.moveToPreviousLevel() + // + // #expect(level.key == key) + // #expect(level.isMajor == isMajor) + // #expect(level.spansMultipleOctaves == true) + // #expect(level.isChromatic == false) + // #expect(level.range == .secondHalfOfOctave) + // + // level = try await manager.moveToPreviousLevel() + // + // #expect(level.key == key) + // #expect(level.isMajor == isMajor) + // #expect(level.spansMultipleOctaves == true) + // #expect(level.isChromatic == false) + // #expect(level.range == .firstHalfOfOctave) + // + // level = try await manager.moveToPreviousLevel() #expect(level.key == key) #expect(level.isMajor == isMajor) @@ -639,7 +639,7 @@ struct CyclicPracticeManagerTests { let level = try await manager.setCurrentLevel( makeLevel(id: 1).withNotes([ - Note(name: .c, octave: 4), + Note(name: .c, octave: 4) ]) ) #expect(level.id == 1) @@ -686,7 +686,7 @@ private struct MockLevelGenerator: LevelGenerator { sessions: [] ) } - + func makeLevel( withId id: Int, inMinorKey key: NoteName, @@ -720,7 +720,7 @@ private final class MockNoteResolutionGenerator: NoteResolutionGenerator { isResolutionInMajorKeyCalled = true return [] } - + func resolution( for note: Note, inMinorKey key: NoteName, @@ -731,7 +731,7 @@ private final class MockNoteResolutionGenerator: NoteResolutionGenerator { } } -private actor MockSessionStorage: SessionStorage { +private final class MockSessionStorage: SessionStorage { var isSaveSessionCalled = false var savedSession: Session? = nil @@ -739,7 +739,7 @@ private actor MockSessionStorage: SessionStorage { isSaveSessionCalled = true savedSession = session } - + func loadSessions(for level: Bemol.Level) async throws -> [Bemol.Session] { [] } @@ -748,24 +748,3 @@ private actor MockSessionStorage: SessionStorage { savedSession } } - -// MARK: - Mocks - -private final class MockPreferences: Preferences { - private var value: Int? = nil - - func value(for key: PreferenceKey) -> Int? { - value - } - - func setValue(_ value: Int, for key: PreferenceKey) { - self.value = value - } - - func value(for key: PreferenceKey) -> Bool { - false - } - - func setValue(_ value: Bool, for key: PreferenceKey) { - } -} diff --git a/BemolTests/Services/DiatonicLevelGeneratorTests.swift b/Tests/Services/DiatonicLevelGeneratorTests.swift similarity index 53% rename from BemolTests/Services/DiatonicLevelGeneratorTests.swift rename to Tests/Services/DiatonicLevelGeneratorTests.swift index af92c5b..46b3f0d 100644 --- a/BemolTests/Services/DiatonicLevelGeneratorTests.swift +++ b/Tests/Services/DiatonicLevelGeneratorTests.swift @@ -21,6 +21,7 @@ import Testing @testable import Bemol +@MainActor struct DiatonicLevelGeneratorTests { // MARK: - Major @@ -41,22 +42,25 @@ struct DiatonicLevelGeneratorTests { #expect(dFlatMajorLevel.spansMultipleOctaves == false) #expect(dFlatMajorLevel.range == .firstHalfOfOctave) #expect(dFlatMajorLevel.sessions.isEmpty == true) - #expect(dFlatMajorLevel.notes == [ - Note(name: .dFlat, octave: 1), - Note(name: .eFlat, octave: 1), - Note(name: .f, octave: 1), - Note(name: .gFlat, octave: 1) - ]) - #expect(dFlatMajorLevel.cadence == Cadence( - voices: [.dFlat, .f, .aFlat], - roots: [.dFlat, .gFlat, .aFlat, .dFlat], - movement: [ - [0, 0, 0], - [0, 1, 2], - [-1, -2, 0], - [0, 0, 0] - ] - )) + #expect( + dFlatMajorLevel.notes == [ + Note(name: .dFlat, octave: 1), + Note(name: .eFlat, octave: 1), + Note(name: .f, octave: 1), + Note(name: .gFlat, octave: 1), + ]) + #expect( + dFlatMajorLevel.cadence + == Cadence( + voices: [.dFlat, .f, .aFlat], + roots: [.dFlat, .gFlat, .aFlat, .dFlat], + movement: [ + [0, 0, 0], + [0, 1, 2], + [-1, -2, 0], + [0, 0, 0], + ] + )) } @Test @@ -76,22 +80,25 @@ struct DiatonicLevelGeneratorTests { #expect(dFlatMajorLevel.spansMultipleOctaves == false) #expect(dFlatMajorLevel.range == .secondHalfOfOctave) #expect(dFlatMajorLevel.sessions.isEmpty == true) - #expect(dFlatMajorLevel.notes == [ - Note(name: .aFlat, octave: 1), - Note(name: .bFlat, octave: 1), - Note(name: .c, octave: 2), - Note(name: .dFlat, octave: 2) - ]) - #expect(dFlatMajorLevel.cadence == Cadence( - voices: [.dFlat, .f, .aFlat], - roots: [.dFlat, .gFlat, .aFlat, .dFlat], - movement: [ - [0, 0, 0], - [0, 1, 2], - [-1, -2, 0], - [0, 0, 0] - ] - )) + #expect( + dFlatMajorLevel.notes == [ + Note(name: .aFlat, octave: 1), + Note(name: .bFlat, octave: 1), + Note(name: .c, octave: 2), + Note(name: .dFlat, octave: 2), + ]) + #expect( + dFlatMajorLevel.cadence + == Cadence( + voices: [.dFlat, .f, .aFlat], + roots: [.dFlat, .gFlat, .aFlat, .dFlat], + movement: [ + [0, 0, 0], + [0, 1, 2], + [-1, -2, 0], + [0, 0, 0], + ] + )) } @Test @@ -111,26 +118,29 @@ struct DiatonicLevelGeneratorTests { #expect(dFlatMajorLevel.spansMultipleOctaves == false) #expect(dFlatMajorLevel.range == .entireOctave) #expect(dFlatMajorLevel.sessions.isEmpty == true) - #expect(dFlatMajorLevel.notes == [ - Note(name: .dFlat, octave: 1), - Note(name: .eFlat, octave: 1), - Note(name: .f, octave: 1), - Note(name: .gFlat, octave: 1), - Note(name: .aFlat, octave: 1), - Note(name: .bFlat, octave: 1), - Note(name: .c, octave: 2), - Note(name: .dFlat, octave: 2) - ]) - #expect(dFlatMajorLevel.cadence == Cadence( - voices: [.dFlat, .f, .aFlat], - roots: [.dFlat, .gFlat, .aFlat, .dFlat], - movement: [ - [0, 0, 0], - [0, 1, 2], - [-1, -2, 0], - [0, 0, 0] - ] - )) + #expect( + dFlatMajorLevel.notes == [ + Note(name: .dFlat, octave: 1), + Note(name: .eFlat, octave: 1), + Note(name: .f, octave: 1), + Note(name: .gFlat, octave: 1), + Note(name: .aFlat, octave: 1), + Note(name: .bFlat, octave: 1), + Note(name: .c, octave: 2), + Note(name: .dFlat, octave: 2), + ]) + #expect( + dFlatMajorLevel.cadence + == Cadence( + voices: [.dFlat, .f, .aFlat], + roots: [.dFlat, .gFlat, .aFlat, .dFlat], + movement: [ + [0, 0, 0], + [0, 1, 2], + [-1, -2, 0], + [0, 0, 0], + ] + )) } @Test @@ -150,24 +160,27 @@ struct DiatonicLevelGeneratorTests { #expect(dFlatMajorLevel.spansMultipleOctaves == false) #expect(dFlatMajorLevel.range == .firstHalfOfOctave) #expect(dFlatMajorLevel.sessions.isEmpty == true) - #expect(dFlatMajorLevel.notes == [ - Note(name: .dFlat, octave: 1), - Note(name: .d, octave: 1), - Note(name: .eFlat, octave: 1), - Note(name: .e, octave: 1), - Note(name: .f, octave: 1), - Note(name: .gFlat, octave: 1) - ]) - #expect(dFlatMajorLevel.cadence == Cadence( - voices: [.dFlat, .f, .aFlat], - roots: [.dFlat, .gFlat, .aFlat, .dFlat], - movement: [ - [0, 0, 0], - [0, 1, 2], - [-1, -2, 0], - [0, 0, 0] - ] - )) + #expect( + dFlatMajorLevel.notes == [ + Note(name: .dFlat, octave: 1), + Note(name: .d, octave: 1), + Note(name: .eFlat, octave: 1), + Note(name: .e, octave: 1), + Note(name: .f, octave: 1), + Note(name: .gFlat, octave: 1), + ]) + #expect( + dFlatMajorLevel.cadence + == Cadence( + voices: [.dFlat, .f, .aFlat], + roots: [.dFlat, .gFlat, .aFlat, .dFlat], + movement: [ + [0, 0, 0], + [0, 1, 2], + [-1, -2, 0], + [0, 0, 0], + ] + )) } @Test @@ -187,25 +200,28 @@ struct DiatonicLevelGeneratorTests { #expect(dFlatMajorLevel.spansMultipleOctaves == false) #expect(dFlatMajorLevel.range == .secondHalfOfOctave) #expect(dFlatMajorLevel.sessions.isEmpty == true) - #expect(dFlatMajorLevel.notes == [ - Note(name: .g, octave: 1), - Note(name: .aFlat, octave: 1), - Note(name: .a, octave: 1), - Note(name: .bFlat, octave: 1), - Note(name: .b, octave: 1), - Note(name: .c, octave: 2), - Note(name: .dFlat, octave: 2) - ]) - #expect(dFlatMajorLevel.cadence == Cadence( - voices: [.dFlat, .f, .aFlat], - roots: [.dFlat, .gFlat, .aFlat, .dFlat], - movement: [ - [0, 0, 0], - [0, 1, 2], - [-1, -2, 0], - [0, 0, 0] - ] - )) + #expect( + dFlatMajorLevel.notes == [ + Note(name: .g, octave: 1), + Note(name: .aFlat, octave: 1), + Note(name: .a, octave: 1), + Note(name: .bFlat, octave: 1), + Note(name: .b, octave: 1), + Note(name: .c, octave: 2), + Note(name: .dFlat, octave: 2), + ]) + #expect( + dFlatMajorLevel.cadence + == Cadence( + voices: [.dFlat, .f, .aFlat], + roots: [.dFlat, .gFlat, .aFlat, .dFlat], + movement: [ + [0, 0, 0], + [0, 1, 2], + [-1, -2, 0], + [0, 0, 0], + ] + )) } @Test @@ -225,34 +241,36 @@ struct DiatonicLevelGeneratorTests { #expect(dFlatMajorLevel.spansMultipleOctaves == false) #expect(dFlatMajorLevel.range == .entireOctave) #expect(dFlatMajorLevel.sessions.isEmpty == true) - #expect(dFlatMajorLevel.notes == [ - Note(name: .dFlat, octave: 1), - Note(name: .d, octave: 1), - Note(name: .eFlat, octave: 1), - Note(name: .e, octave: 1), - Note(name: .f, octave: 1), - Note(name: .gFlat, octave: 1), - Note(name: .g, octave: 1), - Note(name: .aFlat, octave: 1), - Note(name: .a, octave: 1), - Note(name: .bFlat, octave: 1), - Note(name: .b, octave: 1), - Note(name: .c, octave: 2), - Note(name: .dFlat, octave: 2) - ]) - #expect(dFlatMajorLevel.cadence == Cadence( - voices: [.dFlat, .f, .aFlat], - roots: [.dFlat, .gFlat, .aFlat, .dFlat], - movement: [ - [0, 0, 0], - [0, 1, 2], - [-1, -2, 0], - [0, 0, 0] - ] - )) + #expect( + dFlatMajorLevel.notes == [ + Note(name: .dFlat, octave: 1), + Note(name: .d, octave: 1), + Note(name: .eFlat, octave: 1), + Note(name: .e, octave: 1), + Note(name: .f, octave: 1), + Note(name: .gFlat, octave: 1), + Note(name: .g, octave: 1), + Note(name: .aFlat, octave: 1), + Note(name: .a, octave: 1), + Note(name: .bFlat, octave: 1), + Note(name: .b, octave: 1), + Note(name: .c, octave: 2), + Note(name: .dFlat, octave: 2), + ]) + #expect( + dFlatMajorLevel.cadence + == Cadence( + voices: [.dFlat, .f, .aFlat], + roots: [.dFlat, .gFlat, .aFlat, .dFlat], + movement: [ + [0, 0, 0], + [0, 1, 2], + [-1, -2, 0], + [0, 0, 0], + ] + )) } - // MARK: - Minor @Test @@ -272,22 +290,25 @@ struct DiatonicLevelGeneratorTests { #expect(fMinorLevel.spansMultipleOctaves == false) #expect(fMinorLevel.range == .firstHalfOfOctave) #expect(fMinorLevel.sessions.isEmpty == true) - #expect(fMinorLevel.notes == [ - Note(name: .f, octave: 1), - Note(name: .g, octave: 1), - Note(name: .aFlat, octave: 1), - Note(name: .bFlat, octave: 1) - ]) - #expect(fMinorLevel.cadence == Cadence( - voices: [.f, .aFlat, .c], - roots: [.f, .bFlat, .c, .f], - movement: [ - [0, 0, 0], - [0, 2, 1], - [-1, -1, 0], - [0, 0, 0] - ] - )) + #expect( + fMinorLevel.notes == [ + Note(name: .f, octave: 1), + Note(name: .g, octave: 1), + Note(name: .aFlat, octave: 1), + Note(name: .bFlat, octave: 1), + ]) + #expect( + fMinorLevel.cadence + == Cadence( + voices: [.f, .aFlat, .c], + roots: [.f, .bFlat, .c, .f], + movement: [ + [0, 0, 0], + [0, 2, 1], + [-1, -1, 0], + [0, 0, 0], + ] + )) } @Test @@ -307,22 +328,25 @@ struct DiatonicLevelGeneratorTests { #expect(fMinorLevel.spansMultipleOctaves == false) #expect(fMinorLevel.range == .secondHalfOfOctave) #expect(fMinorLevel.sessions.isEmpty == true) - #expect(fMinorLevel.notes == [ - Note(name: .c, octave: 2), - Note(name: .dFlat, octave: 2), - Note(name: .e, octave: 2), - Note(name: .f, octave: 2) - ]) - #expect(fMinorLevel.cadence == Cadence( - voices: [.f, .aFlat, .c], - roots: [.f, .bFlat, .c, .f], - movement: [ - [0, 0, 0], - [0, 2, 1], - [-1, -1, 0], - [0, 0, 0] - ] - )) + #expect( + fMinorLevel.notes == [ + Note(name: .c, octave: 2), + Note(name: .dFlat, octave: 2), + Note(name: .e, octave: 2), + Note(name: .f, octave: 2), + ]) + #expect( + fMinorLevel.cadence + == Cadence( + voices: [.f, .aFlat, .c], + roots: [.f, .bFlat, .c, .f], + movement: [ + [0, 0, 0], + [0, 2, 1], + [-1, -1, 0], + [0, 0, 0], + ] + )) } @Test @@ -342,26 +366,29 @@ struct DiatonicLevelGeneratorTests { #expect(fMinorLevel.spansMultipleOctaves == false) #expect(fMinorLevel.range == .entireOctave) #expect(fMinorLevel.sessions.isEmpty == true) - #expect(fMinorLevel.notes == [ - Note(name: .f, octave: 1), - Note(name: .g, octave: 1), - Note(name: .aFlat, octave: 1), - Note(name: .bFlat, octave: 1), - Note(name: .c, octave: 2), - Note(name: .dFlat, octave: 2), - Note(name: .e, octave: 2), - Note(name: .f, octave: 2) - ]) - #expect(fMinorLevel.cadence == Cadence( - voices: [.f, .aFlat, .c], - roots: [.f, .bFlat, .c, .f], - movement: [ - [0, 0, 0], - [0, 2, 1], - [-1, -1, 0], - [0, 0, 0] - ] - )) + #expect( + fMinorLevel.notes == [ + Note(name: .f, octave: 1), + Note(name: .g, octave: 1), + Note(name: .aFlat, octave: 1), + Note(name: .bFlat, octave: 1), + Note(name: .c, octave: 2), + Note(name: .dFlat, octave: 2), + Note(name: .e, octave: 2), + Note(name: .f, octave: 2), + ]) + #expect( + fMinorLevel.cadence + == Cadence( + voices: [.f, .aFlat, .c], + roots: [.f, .bFlat, .c, .f], + movement: [ + [0, 0, 0], + [0, 2, 1], + [-1, -1, 0], + [0, 0, 0], + ] + )) } @Test @@ -381,24 +408,27 @@ struct DiatonicLevelGeneratorTests { #expect(fMinorLevel.spansMultipleOctaves == false) #expect(fMinorLevel.range == .firstHalfOfOctave) #expect(fMinorLevel.sessions.isEmpty == true) - #expect(fMinorLevel.notes == [ - Note(name: .f, octave: 1), - Note(name: .fSharp, octave: 1), - Note(name: .g, octave: 1), - Note(name: .aFlat, octave: 1), - Note(name: .a, octave: 1), - Note(name: .bFlat, octave: 1) - ]) - #expect(fMinorLevel.cadence == Cadence( - voices: [.f, .aFlat, .c], - roots: [.f, .bFlat, .c, .f], - movement: [ - [0, 0, 0], - [0, 2, 1], - [-1, -1, 0], - [0, 0, 0] - ] - )) + #expect( + fMinorLevel.notes == [ + Note(name: .f, octave: 1), + Note(name: .fSharp, octave: 1), + Note(name: .g, octave: 1), + Note(name: .aFlat, octave: 1), + Note(name: .a, octave: 1), + Note(name: .bFlat, octave: 1), + ]) + #expect( + fMinorLevel.cadence + == Cadence( + voices: [.f, .aFlat, .c], + roots: [.f, .bFlat, .c, .f], + movement: [ + [0, 0, 0], + [0, 2, 1], + [-1, -1, 0], + [0, 0, 0], + ] + )) } @Test @@ -418,25 +448,28 @@ struct DiatonicLevelGeneratorTests { #expect(fMinorLevel.spansMultipleOctaves == false) #expect(fMinorLevel.range == .secondHalfOfOctave) #expect(fMinorLevel.sessions.isEmpty == true) - #expect(fMinorLevel.notes == [ - Note(name: .b, octave: 1), - Note(name: .c, octave: 2), - Note(name: .dFlat, octave: 2), - Note(name: .d, octave: 2), - Note(name: .eFlat, octave: 2), - Note(name: .e, octave: 2), - Note(name: .f, octave: 2) - ]) - #expect(fMinorLevel.cadence == Cadence( - voices: [.f, .aFlat, .c], - roots: [.f, .bFlat, .c, .f], - movement: [ - [0, 0, 0], - [0, 2, 1], - [-1, -1, 0], - [0, 0, 0] - ] - )) + #expect( + fMinorLevel.notes == [ + Note(name: .b, octave: 1), + Note(name: .c, octave: 2), + Note(name: .dFlat, octave: 2), + Note(name: .d, octave: 2), + Note(name: .eFlat, octave: 2), + Note(name: .e, octave: 2), + Note(name: .f, octave: 2), + ]) + #expect( + fMinorLevel.cadence + == Cadence( + voices: [.f, .aFlat, .c], + roots: [.f, .bFlat, .c, .f], + movement: [ + [0, 0, 0], + [0, 2, 1], + [-1, -1, 0], + [0, 0, 0], + ] + )) } @Test @@ -456,30 +489,33 @@ struct DiatonicLevelGeneratorTests { #expect(fMinorLevel.spansMultipleOctaves == false) #expect(fMinorLevel.range == .entireOctave) #expect(fMinorLevel.sessions.isEmpty == true) - #expect(fMinorLevel.notes == [ - Note(name: .f, octave: 1), - Note(name: .fSharp, octave: 1), - Note(name: .g, octave: 1), - Note(name: .aFlat, octave: 1), - Note(name: .a, octave: 1), - Note(name: .bFlat, octave: 1), - Note(name: .b, octave: 1), - Note(name: .c, octave: 2), - Note(name: .dFlat, octave: 2), - Note(name: .d, octave: 2), - Note(name: .eFlat, octave: 2), - Note(name: .e, octave: 2), - Note(name: .f, octave: 2) - ]) - #expect(fMinorLevel.cadence == Cadence( - voices: [.f, .aFlat, .c], - roots: [.f, .bFlat, .c, .f], - movement: [ - [0, 0, 0], - [0, 2, 1], - [-1, -1, 0], - [0, 0, 0] - ] - )) + #expect( + fMinorLevel.notes == [ + Note(name: .f, octave: 1), + Note(name: .fSharp, octave: 1), + Note(name: .g, octave: 1), + Note(name: .aFlat, octave: 1), + Note(name: .a, octave: 1), + Note(name: .bFlat, octave: 1), + Note(name: .b, octave: 1), + Note(name: .c, octave: 2), + Note(name: .dFlat, octave: 2), + Note(name: .d, octave: 2), + Note(name: .eFlat, octave: 2), + Note(name: .e, octave: 2), + Note(name: .f, octave: 2), + ]) + #expect( + fMinorLevel.cadence + == Cadence( + voices: [.f, .aFlat, .c], + roots: [.f, .bFlat, .c, .f], + movement: [ + [0, 0, 0], + [0, 2, 1], + [-1, -1, 0], + [0, 0, 0], + ] + )) } } diff --git a/BemolTests/Services/DiatonicNoteResolutionGeneratorTests.swift b/Tests/Services/DiatonicNoteResolutionGeneratorTests.swift similarity index 54% rename from BemolTests/Services/DiatonicNoteResolutionGeneratorTests.swift rename to Tests/Services/DiatonicNoteResolutionGeneratorTests.swift index f1bf610..c01cd16 100644 --- a/BemolTests/Services/DiatonicNoteResolutionGeneratorTests.swift +++ b/Tests/Services/DiatonicNoteResolutionGeneratorTests.swift @@ -21,13 +21,14 @@ import Testing @testable import Bemol +@MainActor struct DiatonicNoteResolutionGeneratorTests { // MARK: - Major @Test func testResolutionForNotesInCMajor() { let generator = DiatonicNoteResolutionGenerator() - + let first = generator.resolution( for: Note(name: .c, octave: 1), inMajorKey: .c, @@ -49,24 +50,28 @@ struct DiatonicNoteResolutionGeneratorTests { includingChromaticNotes: false ) - #expect(first == [ - Note(name: .c, octave: 1) - ]) - #expect(second == [ - Note(name: .d, octave: 1), - Note(name: .c, octave: 1), - ]) - #expect(third == [ - Note(name: .e, octave: 1), - Note(name: .d, octave: 1), - Note(name: .c, octave: 1), - ]) - #expect(fourth == [ - Note(name: .f, octave: 1), - Note(name: .e, octave: 1), - Note(name: .d, octave: 1), - Note(name: .c, octave: 1), - ]) + #expect( + first == [ + Note(name: .c, octave: 1) + ]) + #expect( + second == [ + Note(name: .d, octave: 1), + Note(name: .c, octave: 1), + ]) + #expect( + third == [ + Note(name: .e, octave: 1), + Note(name: .d, octave: 1), + Note(name: .c, octave: 1), + ]) + #expect( + fourth == [ + Note(name: .f, octave: 1), + Note(name: .e, octave: 1), + Note(name: .d, octave: 1), + Note(name: .c, octave: 1), + ]) let fifth = generator.resolution( for: Note(name: .g, octave: 1), @@ -89,24 +94,28 @@ struct DiatonicNoteResolutionGeneratorTests { includingChromaticNotes: false ) - #expect(fifth == [ - Note(name: .g, octave: 1), - Note(name: .a, octave: 1), - Note(name: .b, octave: 1), - Note(name: .c, octave: 2), - ]) - #expect(sixth == [ - Note(name: .a, octave: 1), - Note(name: .b, octave: 1), - Note(name: .c, octave: 2), - ]) - #expect(seventh == [ - Note(name: .b, octave: 1), - Note(name: .c, octave: 2), - ]) - #expect(nextFirst == [ - Note(name: .c, octave: 2), - ]) + #expect( + fifth == [ + Note(name: .g, octave: 1), + Note(name: .a, octave: 1), + Note(name: .b, octave: 1), + Note(name: .c, octave: 2), + ]) + #expect( + sixth == [ + Note(name: .a, octave: 1), + Note(name: .b, octave: 1), + Note(name: .c, octave: 2), + ]) + #expect( + seventh == [ + Note(name: .b, octave: 1), + Note(name: .c, octave: 2), + ]) + #expect( + nextFirst == [ + Note(name: .c, octave: 2) + ]) } @Test @@ -144,33 +153,39 @@ struct DiatonicNoteResolutionGeneratorTests { includingChromaticNotes: true ) - #expect(first == [ - Note(name: .c, octave: 1) - ]) - #expect(minorSecond == [ - Note(name: .cSharp, octave: 1), - Note(name: .c, octave: 1) - ]) - #expect(second == [ - Note(name: .d, octave: 1), - Note(name: .c, octave: 1), - ]) - #expect(minorThird == [ - Note(name: .eFlat, octave: 1), - Note(name: .d, octave: 1), - Note(name: .c, octave: 1), - ]) - #expect(third == [ - Note(name: .e, octave: 1), - Note(name: .d, octave: 1), - Note(name: .c, octave: 1), - ]) - #expect(fourth == [ - Note(name: .f, octave: 1), - Note(name: .e, octave: 1), - Note(name: .d, octave: 1), - Note(name: .c, octave: 1), - ]) + #expect( + first == [ + Note(name: .c, octave: 1) + ]) + #expect( + minorSecond == [ + Note(name: .cSharp, octave: 1), + Note(name: .c, octave: 1), + ]) + #expect( + second == [ + Note(name: .d, octave: 1), + Note(name: .c, octave: 1), + ]) + #expect( + minorThird == [ + Note(name: .eFlat, octave: 1), + Note(name: .d, octave: 1), + Note(name: .c, octave: 1), + ]) + #expect( + third == [ + Note(name: .e, octave: 1), + Note(name: .d, octave: 1), + Note(name: .c, octave: 1), + ]) + #expect( + fourth == [ + Note(name: .f, octave: 1), + Note(name: .e, octave: 1), + Note(name: .d, octave: 1), + Note(name: .c, octave: 1), + ]) let diminishedFifth = generator.resolution( for: Note(name: .gFlat, octave: 1), @@ -208,42 +223,49 @@ struct DiatonicNoteResolutionGeneratorTests { includingChromaticNotes: true ) - #expect(diminishedFifth == [ - Note(name: .gFlat, octave: 1), - Note(name: .g, octave: 1), - Note(name: .a, octave: 1), - Note(name: .b, octave: 1), - Note(name: .c, octave: 2), - ]) - #expect(fifth == [ - Note(name: .g, octave: 1), - Note(name: .a, octave: 1), - Note(name: .b, octave: 1), - Note(name: .c, octave: 2), - ]) - #expect(augmentedFifth == [ - Note(name: .gSharp, octave: 1), - Note(name: .a, octave: 1), - Note(name: .b, octave: 1), - Note(name: .c, octave: 2), - ]) - #expect(sixth == [ - Note(name: .a, octave: 1), - Note(name: .b, octave: 1), - Note(name: .c, octave: 2), - ]) - #expect(minorSeventh == [ - Note(name: .bFlat, octave: 1), - Note(name: .b, octave: 1), - Note(name: .c, octave: 2), - ]) - #expect(seventh == [ - Note(name: .b, octave: 1), - Note(name: .c, octave: 2), - ]) - #expect(nextFirst == [ - Note(name: .c, octave: 2), - ]) + #expect( + diminishedFifth == [ + Note(name: .gFlat, octave: 1), + Note(name: .g, octave: 1), + Note(name: .a, octave: 1), + Note(name: .b, octave: 1), + Note(name: .c, octave: 2), + ]) + #expect( + fifth == [ + Note(name: .g, octave: 1), + Note(name: .a, octave: 1), + Note(name: .b, octave: 1), + Note(name: .c, octave: 2), + ]) + #expect( + augmentedFifth == [ + Note(name: .gSharp, octave: 1), + Note(name: .a, octave: 1), + Note(name: .b, octave: 1), + Note(name: .c, octave: 2), + ]) + #expect( + sixth == [ + Note(name: .a, octave: 1), + Note(name: .b, octave: 1), + Note(name: .c, octave: 2), + ]) + #expect( + minorSeventh == [ + Note(name: .bFlat, octave: 1), + Note(name: .b, octave: 1), + Note(name: .c, octave: 2), + ]) + #expect( + seventh == [ + Note(name: .b, octave: 1), + Note(name: .c, octave: 2), + ]) + #expect( + nextFirst == [ + Note(name: .c, octave: 2) + ]) } @Test @@ -271,24 +293,28 @@ struct DiatonicNoteResolutionGeneratorTests { includingChromaticNotes: false ) - #expect(first == [ - Note(name: .eFlat, octave: 1) - ]) - #expect(second == [ - Note(name: .f, octave: 1), - Note(name: .eFlat, octave: 1), - ]) - #expect(third == [ - Note(name: .g, octave: 1), - Note(name: .f, octave: 1), - Note(name: .eFlat, octave: 1), - ]) - #expect(fourth == [ - Note(name: .aFlat, octave: 1), - Note(name: .g, octave: 1), - Note(name: .f, octave: 1), - Note(name: .eFlat, octave: 1), - ]) + #expect( + first == [ + Note(name: .eFlat, octave: 1) + ]) + #expect( + second == [ + Note(name: .f, octave: 1), + Note(name: .eFlat, octave: 1), + ]) + #expect( + third == [ + Note(name: .g, octave: 1), + Note(name: .f, octave: 1), + Note(name: .eFlat, octave: 1), + ]) + #expect( + fourth == [ + Note(name: .aFlat, octave: 1), + Note(name: .g, octave: 1), + Note(name: .f, octave: 1), + Note(name: .eFlat, octave: 1), + ]) let fifth = generator.resolution( for: Note(name: .bFlat, octave: 1), @@ -311,24 +337,28 @@ struct DiatonicNoteResolutionGeneratorTests { includingChromaticNotes: false ) - #expect(fifth == [ - Note(name: .bFlat, octave: 1), - Note(name: .c, octave: 2), - Note(name: .d, octave: 2), - Note(name: .eFlat, octave: 2), - ]) - #expect(sixth == [ - Note(name: .c, octave: 2), - Note(name: .d, octave: 2), - Note(name: .eFlat, octave: 2), - ]) - #expect(seventh == [ - Note(name: .d, octave: 2), - Note(name: .eFlat, octave: 2), - ]) - #expect(nextFirst == [ - Note(name: .eFlat, octave: 2), - ]) + #expect( + fifth == [ + Note(name: .bFlat, octave: 1), + Note(name: .c, octave: 2), + Note(name: .d, octave: 2), + Note(name: .eFlat, octave: 2), + ]) + #expect( + sixth == [ + Note(name: .c, octave: 2), + Note(name: .d, octave: 2), + Note(name: .eFlat, octave: 2), + ]) + #expect( + seventh == [ + Note(name: .d, octave: 2), + Note(name: .eFlat, octave: 2), + ]) + #expect( + nextFirst == [ + Note(name: .eFlat, octave: 2) + ]) } @Test @@ -366,33 +396,39 @@ struct DiatonicNoteResolutionGeneratorTests { includingChromaticNotes: true ) - #expect(first == [ - Note(name: .eFlat, octave: 1) - ]) - #expect(minorSecond == [ - Note(name: .e, octave: 1), - Note(name: .eFlat, octave: 1), - ]) - #expect(second == [ - Note(name: .f, octave: 1), - Note(name: .eFlat, octave: 1), - ]) - #expect(minorThird == [ - Note(name: .gFlat, octave: 1), - Note(name: .f, octave: 1), - Note(name: .eFlat, octave: 1), - ]) - #expect(third == [ - Note(name: .g, octave: 1), - Note(name: .f, octave: 1), - Note(name: .eFlat, octave: 1), - ]) - #expect(fourth == [ - Note(name: .aFlat, octave: 1), - Note(name: .g, octave: 1), - Note(name: .f, octave: 1), - Note(name: .eFlat, octave: 1), - ]) + #expect( + first == [ + Note(name: .eFlat, octave: 1) + ]) + #expect( + minorSecond == [ + Note(name: .e, octave: 1), + Note(name: .eFlat, octave: 1), + ]) + #expect( + second == [ + Note(name: .f, octave: 1), + Note(name: .eFlat, octave: 1), + ]) + #expect( + minorThird == [ + Note(name: .gFlat, octave: 1), + Note(name: .f, octave: 1), + Note(name: .eFlat, octave: 1), + ]) + #expect( + third == [ + Note(name: .g, octave: 1), + Note(name: .f, octave: 1), + Note(name: .eFlat, octave: 1), + ]) + #expect( + fourth == [ + Note(name: .aFlat, octave: 1), + Note(name: .g, octave: 1), + Note(name: .f, octave: 1), + Note(name: .eFlat, octave: 1), + ]) let diminishedFifth = generator.resolution( for: Note(name: .a, octave: 1), @@ -430,47 +466,53 @@ struct DiatonicNoteResolutionGeneratorTests { includingChromaticNotes: true ) - #expect(diminishedFifth == [ - Note(name: .a, octave: 1), - Note(name: .bFlat, octave: 1), - Note(name: .c, octave: 2), - Note(name: .d, octave: 2), - Note(name: .eFlat, octave: 2), - ]) - #expect(fifth == [ - Note(name: .bFlat, octave: 1), - Note(name: .c, octave: 2), - Note(name: .d, octave: 2), - Note(name: .eFlat, octave: 2), - ]) - #expect(augmentedFifth == [ - Note(name: .b, octave: 1), - Note(name: .c, octave: 2), - Note(name: .d, octave: 2), - Note(name: .eFlat, octave: 2), - ]) - #expect(sixth == [ - Note(name: .c, octave: 2), - Note(name: .d, octave: 2), - Note(name: .eFlat, octave: 2), - ]) - #expect(minorSeventh == [ - Note(name: .dFlat, octave: 2), - Note(name: .d, octave: 2), - Note(name: .eFlat, octave: 2), - ]) - #expect(seventh == [ - Note(name: .d, octave: 2), - Note(name: .eFlat, octave: 2), - ]) - #expect(nextFirst == [ - Note(name: .eFlat, octave: 2), - ]) + #expect( + diminishedFifth == [ + Note(name: .a, octave: 1), + Note(name: .bFlat, octave: 1), + Note(name: .c, octave: 2), + Note(name: .d, octave: 2), + Note(name: .eFlat, octave: 2), + ]) + #expect( + fifth == [ + Note(name: .bFlat, octave: 1), + Note(name: .c, octave: 2), + Note(name: .d, octave: 2), + Note(name: .eFlat, octave: 2), + ]) + #expect( + augmentedFifth == [ + Note(name: .b, octave: 1), + Note(name: .c, octave: 2), + Note(name: .d, octave: 2), + Note(name: .eFlat, octave: 2), + ]) + #expect( + sixth == [ + Note(name: .c, octave: 2), + Note(name: .d, octave: 2), + Note(name: .eFlat, octave: 2), + ]) + #expect( + minorSeventh == [ + Note(name: .dFlat, octave: 2), + Note(name: .d, octave: 2), + Note(name: .eFlat, octave: 2), + ]) + #expect( + seventh == [ + Note(name: .d, octave: 2), + Note(name: .eFlat, octave: 2), + ]) + #expect( + nextFirst == [ + Note(name: .eFlat, octave: 2) + ]) } // MARK: - Minor - @Test func testResolutionForNotesInCMinor() { let generator = DiatonicNoteResolutionGenerator() @@ -496,24 +538,28 @@ struct DiatonicNoteResolutionGeneratorTests { includingChromaticNotes: false ) - #expect(first == [ - Note(name: .c, octave: 1) - ]) - #expect(second == [ - Note(name: .d, octave: 1), - Note(name: .c, octave: 1), - ]) - #expect(minorThird == [ - Note(name: .eFlat, octave: 1), - Note(name: .d, octave: 1), - Note(name: .c, octave: 1), - ]) - #expect(fourth == [ - Note(name: .f, octave: 1), - Note(name: .eFlat, octave: 1), - Note(name: .d, octave: 1), - Note(name: .c, octave: 1), - ]) + #expect( + first == [ + Note(name: .c, octave: 1) + ]) + #expect( + second == [ + Note(name: .d, octave: 1), + Note(name: .c, octave: 1), + ]) + #expect( + minorThird == [ + Note(name: .eFlat, octave: 1), + Note(name: .d, octave: 1), + Note(name: .c, octave: 1), + ]) + #expect( + fourth == [ + Note(name: .f, octave: 1), + Note(name: .eFlat, octave: 1), + Note(name: .d, octave: 1), + Note(name: .c, octave: 1), + ]) let fifth = generator.resolution( for: Note(name: .g, octave: 1), @@ -536,29 +582,34 @@ struct DiatonicNoteResolutionGeneratorTests { includingChromaticNotes: false ) - #expect(fifth == [ - Note(name: .g, octave: 1), - Note(name: .gSharp, octave: 1), - Note(name: .b, octave: 1), - Note(name: .c, octave: 2), - ]) - - #expect(augmentedFifth == [ - Note(name: .gSharp, octave: 1), - Note(name: .b, octave: 1), - Note(name: .c, octave: 2), - ]) - #expect(raisedSixth == [ - Note(name: .b, octave: 1), - Note(name: .c, octave: 2), - ]) - #expect(raisedSixth == [ - Note(name: .b, octave: 1), - Note(name: .c, octave: 2), - ]) - #expect(nextFirst == [ - Note(name: .c, octave: 2), - ]) + #expect( + fifth == [ + Note(name: .g, octave: 1), + Note(name: .gSharp, octave: 1), + Note(name: .b, octave: 1), + Note(name: .c, octave: 2), + ]) + + #expect( + augmentedFifth == [ + Note(name: .gSharp, octave: 1), + Note(name: .b, octave: 1), + Note(name: .c, octave: 2), + ]) + #expect( + raisedSixth == [ + Note(name: .b, octave: 1), + Note(name: .c, octave: 2), + ]) + #expect( + raisedSixth == [ + Note(name: .b, octave: 1), + Note(name: .c, octave: 2), + ]) + #expect( + nextFirst == [ + Note(name: .c, octave: 2) + ]) } @Test @@ -596,34 +647,40 @@ struct DiatonicNoteResolutionGeneratorTests { includingChromaticNotes: true ) - #expect(first == [ - Note(name: .c, octave: 1) - ]) - #expect(minorSecond == [ - Note(name: .dFlat, octave: 1), - Note(name: .c, octave: 1), - ]) - #expect(second == [ - Note(name: .d, octave: 1), - Note(name: .c, octave: 1), - ]) - #expect(minorThird == [ - Note(name: .eFlat, octave: 1), - Note(name: .d, octave: 1), - Note(name: .c, octave: 1), - ]) - #expect(majorThird == [ - Note(name: .e, octave: 1), - Note(name: .eFlat, octave: 1), - Note(name: .d, octave: 1), - Note(name: .c, octave: 1), - ]) - #expect(fourth == [ - Note(name: .f, octave: 1), - Note(name: .eFlat, octave: 1), - Note(name: .d, octave: 1), - Note(name: .c, octave: 1), - ]) + #expect( + first == [ + Note(name: .c, octave: 1) + ]) + #expect( + minorSecond == [ + Note(name: .dFlat, octave: 1), + Note(name: .c, octave: 1), + ]) + #expect( + second == [ + Note(name: .d, octave: 1), + Note(name: .c, octave: 1), + ]) + #expect( + minorThird == [ + Note(name: .eFlat, octave: 1), + Note(name: .d, octave: 1), + Note(name: .c, octave: 1), + ]) + #expect( + majorThird == [ + Note(name: .e, octave: 1), + Note(name: .eFlat, octave: 1), + Note(name: .d, octave: 1), + Note(name: .c, octave: 1), + ]) + #expect( + fourth == [ + Note(name: .f, octave: 1), + Note(name: .eFlat, octave: 1), + Note(name: .d, octave: 1), + Note(name: .c, octave: 1), + ]) let diminishedFifth = generator.resolution( for: Note(name: .gFlat, octave: 1), @@ -656,36 +713,42 @@ struct DiatonicNoteResolutionGeneratorTests { includingChromaticNotes: true ) - #expect(diminishedFifth == [ - Note(name: .gFlat, octave: 1), - Note(name: .g, octave: 1), - Note(name: .gSharp, octave: 1), - Note(name: .b, octave: 1), - Note(name: .c, octave: 2), - ]) - #expect(fifth == [ - Note(name: .g, octave: 1), - Note(name: .gSharp, octave: 1), - Note(name: .b, octave: 1), - Note(name: .c, octave: 2), - ]) - #expect(augmentedFifth == [ - Note(name: .gSharp, octave: 1), - Note(name: .b, octave: 1), - Note(name: .c, octave: 2), - ]) - #expect(sixth == [ - Note(name: .a, octave: 1), - Note(name: .b, octave: 1), - Note(name: .c, octave: 2), - ]) - #expect(raisedSixth == [ - Note(name: .b, octave: 1), - Note(name: .c, octave: 2), - ]) - #expect(nextFirst == [ - Note(name: .c, octave: 2), - ]) + #expect( + diminishedFifth == [ + Note(name: .gFlat, octave: 1), + Note(name: .g, octave: 1), + Note(name: .gSharp, octave: 1), + Note(name: .b, octave: 1), + Note(name: .c, octave: 2), + ]) + #expect( + fifth == [ + Note(name: .g, octave: 1), + Note(name: .gSharp, octave: 1), + Note(name: .b, octave: 1), + Note(name: .c, octave: 2), + ]) + #expect( + augmentedFifth == [ + Note(name: .gSharp, octave: 1), + Note(name: .b, octave: 1), + Note(name: .c, octave: 2), + ]) + #expect( + sixth == [ + Note(name: .a, octave: 1), + Note(name: .b, octave: 1), + Note(name: .c, octave: 2), + ]) + #expect( + raisedSixth == [ + Note(name: .b, octave: 1), + Note(name: .c, octave: 2), + ]) + #expect( + nextFirst == [ + Note(name: .c, octave: 2) + ]) } @Test @@ -713,24 +776,28 @@ struct DiatonicNoteResolutionGeneratorTests { includingChromaticNotes: false ) - #expect(first == [ - Note(name: .eFlat, octave: 1) - ]) - #expect(second == [ - Note(name: .f, octave: 1), - Note(name: .eFlat, octave: 1), - ]) - #expect(minorThird == [ - Note(name: .gFlat, octave: 1), - Note(name: .f, octave: 1), - Note(name: .eFlat, octave: 1), - ]) - #expect(fourth == [ - Note(name: .aFlat, octave: 1), - Note(name: .gFlat, octave: 1), - Note(name: .f, octave: 1), - Note(name: .eFlat, octave: 1), - ]) + #expect( + first == [ + Note(name: .eFlat, octave: 1) + ]) + #expect( + second == [ + Note(name: .f, octave: 1), + Note(name: .eFlat, octave: 1), + ]) + #expect( + minorThird == [ + Note(name: .gFlat, octave: 1), + Note(name: .f, octave: 1), + Note(name: .eFlat, octave: 1), + ]) + #expect( + fourth == [ + Note(name: .aFlat, octave: 1), + Note(name: .gFlat, octave: 1), + Note(name: .f, octave: 1), + Note(name: .eFlat, octave: 1), + ]) let fifth = generator.resolution( for: Note(name: .bFlat, octave: 1), @@ -753,24 +820,28 @@ struct DiatonicNoteResolutionGeneratorTests { includingChromaticNotes: false ) - #expect(fifth == [ - Note(name: .bFlat, octave: 1), - Note(name: .b, octave: 1), - Note(name: .d, octave: 2), - Note(name: .eFlat, octave: 2), - ]) - #expect(augmentedFifth == [ - Note(name: .b, octave: 1), - Note(name: .d, octave: 2), - Note(name: .eFlat, octave: 2), - ]) - #expect(raisedSixth == [ - Note(name: .d, octave: 2), - Note(name: .eFlat, octave: 2), - ]) - #expect(nextFirst == [ - Note(name: .eFlat, octave: 2), - ]) + #expect( + fifth == [ + Note(name: .bFlat, octave: 1), + Note(name: .b, octave: 1), + Note(name: .d, octave: 2), + Note(name: .eFlat, octave: 2), + ]) + #expect( + augmentedFifth == [ + Note(name: .b, octave: 1), + Note(name: .d, octave: 2), + Note(name: .eFlat, octave: 2), + ]) + #expect( + raisedSixth == [ + Note(name: .d, octave: 2), + Note(name: .eFlat, octave: 2), + ]) + #expect( + nextFirst == [ + Note(name: .eFlat, octave: 2) + ]) } @Test @@ -803,30 +874,35 @@ struct DiatonicNoteResolutionGeneratorTests { includingChromaticNotes: true ) - #expect(first == [ - Note(name: .eFlat, octave: 1) - ]) - #expect(second == [ - Note(name: .f, octave: 1), - Note(name: .eFlat, octave: 1), - ]) - #expect(minorThird == [ - Note(name: .gFlat, octave: 1), - Note(name: .f, octave: 1), - Note(name: .eFlat, octave: 1), - ]) - #expect(majorThird == [ - Note(name: .g, octave: 1), - Note(name: .gFlat, octave: 1), - Note(name: .f, octave: 1), - Note(name: .eFlat, octave: 1), - ]) - #expect(fourth == [ - Note(name: .aFlat, octave: 1), - Note(name: .gFlat, octave: 1), - Note(name: .f, octave: 1), - Note(name: .eFlat, octave: 1), - ]) + #expect( + first == [ + Note(name: .eFlat, octave: 1) + ]) + #expect( + second == [ + Note(name: .f, octave: 1), + Note(name: .eFlat, octave: 1), + ]) + #expect( + minorThird == [ + Note(name: .gFlat, octave: 1), + Note(name: .f, octave: 1), + Note(name: .eFlat, octave: 1), + ]) + #expect( + majorThird == [ + Note(name: .g, octave: 1), + Note(name: .gFlat, octave: 1), + Note(name: .f, octave: 1), + Note(name: .eFlat, octave: 1), + ]) + #expect( + fourth == [ + Note(name: .aFlat, octave: 1), + Note(name: .gFlat, octave: 1), + Note(name: .f, octave: 1), + Note(name: .eFlat, octave: 1), + ]) let diminishedFifth = generator.resolution( for: Note(name: .a, octave: 1), @@ -864,40 +940,47 @@ struct DiatonicNoteResolutionGeneratorTests { includingChromaticNotes: true ) - #expect(diminishedFifth == [ - Note(name: .a, octave: 1), - Note(name: .bFlat, octave: 1), - Note(name: .b, octave: 1), - Note(name: .d, octave: 2), - Note(name: .eFlat, octave: 2), - ]) - #expect(fifth == [ - Note(name: .bFlat, octave: 1), - Note(name: .b, octave: 1), - Note(name: .d, octave: 2), - Note(name: .eFlat, octave: 2), - ]) - #expect(augmentedFifth == [ - Note(name: .b, octave: 1), - Note(name: .d, octave: 2), - Note(name: .eFlat, octave: 2), - ]) - #expect(sixth == [ - Note(name: .c, octave: 2), - Note(name: .d, octave: 2), - Note(name: .eFlat, octave: 2), - ]) - #expect(raisedSixth == [ - Note(name: .dFlat, octave: 2), - Note(name: .d, octave: 2), - Note(name: .eFlat, octave: 2), - ]) - #expect(seventh == [ - Note(name: .d, octave: 2), - Note(name: .eFlat, octave: 2), - ]) - #expect(nextFirst == [ - Note(name: .eFlat, octave: 2), - ]) + #expect( + diminishedFifth == [ + Note(name: .a, octave: 1), + Note(name: .bFlat, octave: 1), + Note(name: .b, octave: 1), + Note(name: .d, octave: 2), + Note(name: .eFlat, octave: 2), + ]) + #expect( + fifth == [ + Note(name: .bFlat, octave: 1), + Note(name: .b, octave: 1), + Note(name: .d, octave: 2), + Note(name: .eFlat, octave: 2), + ]) + #expect( + augmentedFifth == [ + Note(name: .b, octave: 1), + Note(name: .d, octave: 2), + Note(name: .eFlat, octave: 2), + ]) + #expect( + sixth == [ + Note(name: .c, octave: 2), + Note(name: .d, octave: 2), + Note(name: .eFlat, octave: 2), + ]) + #expect( + raisedSixth == [ + Note(name: .dFlat, octave: 2), + Note(name: .d, octave: 2), + Note(name: .eFlat, octave: 2), + ]) + #expect( + seventh == [ + Note(name: .d, octave: 2), + Note(name: .eFlat, octave: 2), + ]) + #expect( + nextFirst == [ + Note(name: .eFlat, octave: 2) + ]) } } diff --git a/BemolTests/Services/FileSessionStorageTests.swift b/Tests/Services/FileSessionStorageTests.swift similarity index 65% rename from BemolTests/Services/FileSessionStorageTests.swift rename to Tests/Services/FileSessionStorageTests.swift index 67383bd..e4ab967 100644 --- a/BemolTests/Services/FileSessionStorageTests.swift +++ b/Tests/Services/FileSessionStorageTests.swift @@ -37,11 +37,11 @@ struct FileSessionStorageTests { let session = try #require(sessions.first) - #expect(session.timestamp == 1745827200) + #expect(session.timestamp == 1_745_827_200) #expect(session.score.count == 3) - #expect(session.score[Note(name: .c, octave: 1)] ?? (0, 0) == (12, 14)) - #expect(session.score[Note(name: .d, octave: 2)] ?? (0, 0) == (0, 5)) - #expect(session.score[Note(name: .gFlat, octave: 1)] ?? (0, 0) == (4, 45)) + #expect(session.score[Note(name: .c, octave: 1)] ?? .zero == .init(correct: 12, wrong: 14)) + #expect(session.score[Note(name: .d, octave: 2)] ?? .zero == .init(correct: 0, wrong: 5)) + #expect(session.score[Note(name: .gFlat, octave: 1)] ?? .zero == .init(correct: 4, wrong: 45)) try? FileManager.default.removeItem(at: sessionsFile) } @@ -62,26 +62,26 @@ struct FileSessionStorageTests { #expect(sessions.count == 3) let first = sessions[0] - #expect(first.timestamp == 1745827200) + #expect(first.timestamp == 1_745_827_200) #expect(first.score.count == 3) - #expect(first.score[Note(name: .c, octave: 1)] ?? (0, 0) == (12, 14)) - #expect(first.score[Note(name: .d, octave: 2)] ?? (0, 0) == (0, 5)) - #expect(first.score[Note(name: .gFlat, octave: 1)] ?? (0, 0) == (4, 45)) + #expect(first.score[Note(name: .c, octave: 1)] ?? .zero == .init(correct: 12, wrong: 14)) + #expect(first.score[Note(name: .d, octave: 2)] ?? .zero == .init(correct: 0, wrong: 5)) + #expect(first.score[Note(name: .gFlat, octave: 1)] ?? .zero == .init(correct: 4, wrong: 45)) let second = sessions[1] - #expect(second.timestamp == 1745827300) + #expect(second.timestamp == 1_745_827_300) #expect(second.score.count == 4) - #expect(second.score[Note(name: .g, octave: 1)] ?? (0, 0) == (15, 9)) - #expect(second.score[Note(name: .fSharp, octave: 2)] ?? (0, 0) == (4, 3)) - #expect(second.score[Note(name: .dFlat, octave: 1)] ?? (0, 0) == (8, 24)) - #expect(second.score[Note(name: .e, octave: 2)] ?? (0, 0) == (1, 0)) + #expect(second.score[Note(name: .g, octave: 1)] ?? .zero == .init(correct: 15, wrong: 9)) + #expect(second.score[Note(name: .fSharp, octave: 2)] ?? .zero == .init(correct: 4, wrong: 3)) + #expect(second.score[Note(name: .dFlat, octave: 1)] ?? .zero == .init(correct: 8, wrong: 24)) + #expect(second.score[Note(name: .e, octave: 2)] ?? .zero == .init(correct: 1, wrong: 0)) let third = sessions[2] - #expect(third.timestamp == 1745827400) + #expect(third.timestamp == 1_745_827_400) #expect(third.score.count == 3) - #expect(third.score[Note(name: .a, octave: 1)] ?? (0, 0) == (10, 0)) - #expect(third.score[Note(name: .c, octave: 2)] ?? (0, 0) == (50, 24)) - #expect(third.score[Note(name: .b, octave: 2)] ?? (0, 0) == (41, 20)) + #expect(third.score[Note(name: .a, octave: 1)] ?? .zero == .init(correct: 10, wrong: 0)) + #expect(third.score[Note(name: .c, octave: 2)] ?? .zero == .init(correct: 50, wrong: 24)) + #expect(third.score[Note(name: .b, octave: 2)] ?? .zero == .init(correct: 41, wrong: 20)) try FileManager.default.removeItem(at: sessionsFile) } @@ -100,11 +100,11 @@ struct FileSessionStorageTests { let session = try #require(sessions.first) - #expect(session.timestamp == 1745827200) + #expect(session.timestamp == 1_745_827_200) #expect(session.score.count == 3) - #expect(session.score[Note(name: .c, octave: 1)] ?? (0, 0) == (12, 14)) - #expect(session.score[Note(name: .d, octave: 2)] ?? (0, 0) == (0, 5)) - #expect(session.score[Note(name: .e, octave: 1)] ?? (0, 0) == (4, 45)) + #expect(session.score[Note(name: .c, octave: 1)] ?? .zero == .init(correct: 12, wrong: 14)) + #expect(session.score[Note(name: .d, octave: 2)] ?? .zero == .init(correct: 0, wrong: 5)) + #expect(session.score[Note(name: .e, octave: 1)] ?? .zero == .init(correct: 4, wrong: 45)) try FileManager.default.removeItem(at: sessionsFile) } @@ -128,26 +128,26 @@ struct FileSessionStorageTests { #expect(sessions.count == 3) let first = sessions[0] - #expect(first.timestamp == 1745827200) + #expect(first.timestamp == 1_745_827_200) #expect(first.score.count == 3) - #expect(first.score[Note(name: .c, octave: 1)] ?? (0, 0) == (12, 14)) - #expect(first.score[Note(name: .d, octave: 2)] ?? (0, 0) == (0, 5)) - #expect(first.score[Note(name: .gFlat, octave: 1)] ?? (0, 0) == (4, 45)) + #expect(first.score[Note(name: .c, octave: 1)] ?? .zero == .init(correct: 12, wrong: 14)) + #expect(first.score[Note(name: .d, octave: 2)] ?? .zero == .init(correct: 0, wrong: 5)) + #expect(first.score[Note(name: .gFlat, octave: 1)] ?? .zero == .init(correct: 4, wrong: 45)) let second = sessions[1] - #expect(second.timestamp == 1745827300) + #expect(second.timestamp == 1_745_827_300) #expect(second.score.count == 4) - #expect(second.score[Note(name: .g, octave: 1)] ?? (0, 0) == (15, 9)) - #expect(second.score[Note(name: .fSharp, octave: 2)] ?? (0, 0) == (4, 3)) - #expect(second.score[Note(name: .dFlat, octave: 1)] ?? (0, 0) == (8, 24)) - #expect(second.score[Note(name: .e, octave: 2)] ?? (0, 0) == (1, 0)) + #expect(second.score[Note(name: .g, octave: 1)] ?? .zero == .init(correct: 15, wrong: 9)) + #expect(second.score[Note(name: .fSharp, octave: 2)] ?? .zero == .init(correct: 4, wrong: 3)) + #expect(second.score[Note(name: .dFlat, octave: 1)] ?? .zero == .init(correct: 8, wrong: 24)) + #expect(second.score[Note(name: .e, octave: 2)] ?? .zero == .init(correct: 1, wrong: 0)) let third = sessions[2] - #expect(third.timestamp == 1745827400) + #expect(third.timestamp == 1_745_827_400) #expect(third.score.count == 3) - #expect(third.score[Note(name: .a, octave: 1)] ?? (0, 0) == (10, 0)) - #expect(third.score[Note(name: .c, octave: 2)] ?? (0, 0) == (50, 24)) - #expect(third.score[Note(name: .b, octave: 2)] ?? (0, 0) == (41, 20)) + #expect(third.score[Note(name: .a, octave: 1)] ?? .zero == .init(correct: 10, wrong: 0)) + #expect(third.score[Note(name: .c, octave: 2)] ?? .zero == .init(correct: 50, wrong: 24)) + #expect(third.score[Note(name: .b, octave: 2)] ?? .zero == .init(correct: 41, wrong: 20)) try FileManager.default.removeItem(at: sessionsFile) } @@ -168,7 +168,7 @@ struct FileSessionStorageTests { let session = try #require(sessions.first) for note in NoteName.all { - #expect(session.score[Note(name: note, octave: 1)] ?? (0, 0) == (1, 0)) + #expect(session.score[Note(name: note, octave: 1)] ?? .zero == .init(correct: 1, wrong: 0)) } try FileManager.default.removeItem(at: sessionsFile) @@ -190,7 +190,7 @@ struct FileSessionStorageTests { let session = try #require(sessions.first) for note in NoteName.all { - #expect(session.score[Note(name: note, octave: 1)] ?? (0, 0) == (1, 0)) + #expect(session.score[Note(name: note, octave: 1)] ?? .zero == .init(correct: 1, wrong: 0)) } try FileManager.default.removeItem(at: sessionsFile) @@ -212,7 +212,7 @@ struct FileSessionStorageTests { let session = try #require(sessions.first) for note in NoteName.all { - #expect(session.score[Note(name: note, octave: 1)] ?? (0, 0) == (1, 0)) + #expect(session.score[Note(name: note, octave: 1)] ?? .zero == .init(correct: 1, wrong: 0)) } try FileManager.default.removeItem(at: sessionsFile) @@ -225,9 +225,9 @@ struct FileSessionStorageTests { let session = Session( timestamp: now, score: [ - Note(name: .c, octave: 1): (12, 34), - Note(name: .bFlat, octave: 1): (32, 45), - Note(name: .d, octave: 2): (0, 32), + Note(name: .c, octave: 1): .init(correct: 12, wrong: 34), + Note(name: .bFlat, octave: 1): .init(correct: 32, wrong: 45), + Note(name: .d, octave: 2): .init(correct: 0, wrong: 32), ] ) @@ -249,24 +249,24 @@ struct FileSessionStorageTests { Session( timestamp: now, score: [ - Note(name: .c, octave: 1): (12, 34), - Note(name: .bFlat, octave: 1): (32, 45), - Note(name: .d, octave: 2): (0, 32), + Note(name: .c, octave: 1): .init(correct: 12, wrong: 34), + Note(name: .bFlat, octave: 1): .init(correct: 32, wrong: 45), + Note(name: .d, octave: 2): .init(correct: 0, wrong: 32), ] ), Session( timestamp: now + 100, score: [ - Note(name: .aFlat, octave: 1): (1, 94), - Note(name: .d, octave: 2): (0, 43), - Note(name: .b, octave: 1): (1, 4), + Note(name: .aFlat, octave: 1): .init(correct: 1, wrong: 94), + Note(name: .d, octave: 2): .init(correct: 0, wrong: 43), + Note(name: .b, octave: 1): .init(correct: 1, wrong: 4), ] ), Session( timestamp: now + 200, score: [ - Note(name: .c, octave: 1): (1, 3), - Note(name: .d, octave: 1): (4, 21), + Note(name: .c, octave: 1): .init(correct: 1, wrong: 3), + Note(name: .d, octave: 1): .init(correct: 4, wrong: 21), ] ), ] @@ -279,11 +279,12 @@ struct FileSessionStorageTests { let sessionsFile = try sessionsFileURL(for: level.id) let contents = try String(contentsOf: sessionsFile, encoding: .utf8) - #expect(contents == [ - "1745827200.0;c:1:12:34,d:2:0:32,asharp:1:32:45", - "1745827300.0;d:2:0:43,gsharp:1:1:94,b:1:1:4", - "1745827400.0;c:1:1:3,d:1:4:21" - ].joined(separator: "\n") + "\n") + #expect( + contents == [ + "1745827200.0;c:1:12:34,d:2:0:32,asharp:1:32:45", + "1745827300.0;d:2:0:43,gsharp:1:1:94,b:1:1:4", + "1745827400.0;c:1:1:3,d:1:4:21", + ].joined(separator: "\n") + "\n") try FileManager.default.removeItem(at: sessionsFile) } @@ -296,17 +297,17 @@ struct FileSessionStorageTests { Session( timestamp: now, score: [ - Note(name: .c, octave: 1): (12, 34), - Note(name: .bFlat, octave: 1): (32, 45), - Note(name: .d, octave: 2): (0, 32), + Note(name: .c, octave: 1): .init(correct: 12, wrong: 34), + Note(name: .bFlat, octave: 1): .init(correct: 32, wrong: 45), + Note(name: .d, octave: 2): .init(correct: 0, wrong: 32), ] ), Session( timestamp: now + 100, score: [ - Note(name: .aFlat, octave: 1): (1, 94), - Note(name: .d, octave: 2): (0, 43), - Note(name: .b, octave: 1): (1, 4), + Note(name: .aFlat, octave: 1): .init(correct: 1, wrong: 94), + Note(name: .d, octave: 2): .init(correct: 0, wrong: 43), + Note(name: .b, octave: 1): .init(correct: 1, wrong: 4), ] ), Session( @@ -316,8 +317,8 @@ struct FileSessionStorageTests { Session( timestamp: now + 200, score: [ - Note(name: .c, octave: 1): (1, 3), - Note(name: .d, octave: 1): (4, 21), + Note(name: .c, octave: 1): .init(correct: 1, wrong: 3), + Note(name: .d, octave: 1): .init(correct: 4, wrong: 21), ] ), Session( @@ -334,11 +335,12 @@ struct FileSessionStorageTests { let sessionsFile = try sessionsFileURL(for: level.id) let contents = try String(contentsOf: sessionsFile, encoding: .utf8) - #expect(contents == [ - "1745827200.0;c:1:12:34,d:2:0:32,asharp:1:32:45", - "1745827300.0;d:2:0:43,gsharp:1:1:94,b:1:1:4", - "1745827400.0;c:1:1:3,d:1:4:21" - ].joined(separator: "\n") + "\n") + #expect( + contents == [ + "1745827200.0;c:1:12:34,d:2:0:32,asharp:1:32:45", + "1745827300.0;d:2:0:43,gsharp:1:1:94,b:1:1:4", + "1745827400.0;c:1:1:3,d:1:4:21", + ].joined(separator: "\n") + "\n") try FileManager.default.removeItem(at: sessionsFile) } @@ -348,9 +350,11 @@ struct FileSessionStorageTests { let now: TimeInterval = 1745827200.0 let level = makeLevel(id: 45) - let notes: [NoteName] = [.c, .cSharp, .d, .dSharp, .e, .f, .fSharp, .g, .gSharp, .a, .aSharp, .b] - var score: [Note: (Int, Int)] = [:] - notes.forEach { score[Note(name: $0, octave: 1)] = (1, 0)} + let notes: [NoteName] = [ + .c, .cSharp, .d, .dSharp, .e, .f, .fSharp, .g, .gSharp, .a, .aSharp, .b, + ] + var score: [Note: Score] = [:] + notes.forEach { score[Note(name: $0, octave: 1)] = .init(correct: 1, wrong: 0) } let session = Session(timestamp: now, score: score) @@ -359,7 +363,10 @@ struct FileSessionStorageTests { let sessionsFile = try sessionsFileURL(for: level.id) let contents = try String(contentsOf: sessionsFile, encoding: .utf8) - #expect(contents == "1745827200.0;c:1:1:0,csharp:1:1:0,d:1:1:0,dsharp:1:1:0,e:1:1:0,f:1:1:0,fsharp:1:1:0,g:1:1:0,gsharp:1:1:0,a:1:1:0,asharp:1:1:0,b:1:1:0\n") + #expect( + contents + == "1745827200.0;c:1:1:0,csharp:1:1:0,d:1:1:0,dsharp:1:1:0,e:1:1:0,f:1:1:0,fsharp:1:1:0,g:1:1:0,gsharp:1:1:0,a:1:1:0,asharp:1:1:0,b:1:1:0\n" + ) try FileManager.default.removeItem(at: sessionsFile) } @@ -370,8 +377,8 @@ struct FileSessionStorageTests { let level = makeLevel(id: 46) let notes: [NoteName] = [.c, .dFlat, .d, .eFlat, .e, .f, .gFlat, .g, .aFlat, .a, .bFlat, .b] - var score: [Note: (Int, Int)] = [:] - notes.forEach { score[Note(name: $0, octave: 1)] = (1, 0)} + var score: [Note: Score] = [:] + notes.forEach { score[Note(name: $0, octave: 1)] = .init(correct: 1, wrong: 0) } let session = Session(timestamp: now, score: score) @@ -380,7 +387,10 @@ struct FileSessionStorageTests { let sessionsFile = try sessionsFileURL(for: level.id) let contents = try String(contentsOf: sessionsFile, encoding: .utf8) - #expect(contents == "1745827200.0;c:1:1:0,csharp:1:1:0,d:1:1:0,dsharp:1:1:0,e:1:1:0,f:1:1:0,fsharp:1:1:0,g:1:1:0,gsharp:1:1:0,a:1:1:0,asharp:1:1:0,b:1:1:0\n") + #expect( + contents + == "1745827200.0;c:1:1:0,csharp:1:1:0,d:1:1:0,dsharp:1:1:0,e:1:1:0,f:1:1:0,fsharp:1:1:0,g:1:1:0,gsharp:1:1:0,a:1:1:0,asharp:1:1:0,b:1:1:0\n" + ) try FileManager.default.removeItem(at: sessionsFile) } diff --git a/alt-store.json b/alt-store.json deleted file mode 100644 index 8bcec9d..0000000 --- a/alt-store.json +++ /dev/null @@ -1,96 +0,0 @@ -{ - "name": "Bemol", - "subtitle": "A source for Bemol, a free and open-source ear training app.", - "description": "🎵 Bemol is a free and open-source ear training app that helps music hobbyists and music students train and develop relative pitch.", - "website": "https://github.com/ftchirou/Bemol", - "tintColor": "#36454F", - "featuredApps": [ - "com.tchirou.apps.bemol" - ], - "apps": [ - { - "name": "Bemol", - "bundleIdentifier": "com.tchirou.apps.bemol", - "marketplaceID": "6745277413", - "developerName": "Faiçal Tchirou", - "subtitle": "Free and open-source functional ear trainer", - "localizedDescription": "Bemol is a free and open-source functional ear training app that helps you develop relative pitch. Bemol helps you learn to recognize each musical note by having you internalize how the note relates to its nearest tonic in a given key. This is achieved through practice sessions where Bemol will play some notes and you identify them by playing them back on the keyboard.\n\nFor each note that you have to identify, Bemol will first play a short cadence to establish the key in your ear. Once you identify the note, Bemol will play a short melody to resolve the note back to the tonic. It is this resolution that will cement the relationship between the note and the tonic in your ear. Do not worry if at first, you're getting most of the notes wrong. It's part of the process. Just relax and listen to the final resolution. Your brain will connect the dots.\n\nPractice a little bit everyday and eventually you will be able to clearly distinguish the qualities of each note in a given key.\n\nHowever, please note that while being able to identify individual notes is a fundamental and valuable skill, it alone will not automatically enable you to \"play by ear\" complex pieces or songs. Ideally, Bemol should be a part of a balanced ear practice regimen that includes rhythm training, chord quality recognition, tension/resolution perception, and a little bit of music theory.\n\nBemol is a free and open-source software. We will never run ads, collect your data, or try to monetize your experience in the app in any way. Bemol is and will be free forever.\n\nBemol is an open-source adaptation of the excellent and free Functional Ear Trainer app developed by Alain Benbassat for desktop operating systems. All credit for the training method is due to Alain.\n\nThe source code is available on Github at https://github.com/ftchirou/Bemol.", - "iconURL": "https://storage.googleapis.com/bemol/images/app-icon/bemol.png", - "tintColor": "#36454F", - "category": "other", - "screenshots": { - "iphone": [ - { - "imageURL": "https://storage.googleapis.com/bemol/images/iphone/bemol-app-store-1.png", - "width": 2796, - "height": 1290 - }, - { - "imageURL": "https://storage.googleapis.com/bemol/images/iphone/bemol-app-store-2.png", - "width": 2796, - "height": 1290 - - }, - { - "imageURL": "https://storage.googleapis.com/bemol/images/iphone/bemol-app-store-3.png", - "width": 2796, - "height": 1290 - }, - { - "imageURL": "https://storage.googleapis.com/bemol/images/iphone/bemol-app-store-4.png", - "width": 2796, - "height": 1290 - }, - { - "imageURL": "https://storage.googleapis.com/bemol/images/iphone/bemol-app-store-5.png", - "width": 2796, - "height": 1290 - } - ], - "ipad": [ - { - "imageURL": "https://storage.googleapis.com/bemol/images/ipad/bemol-app-store-ipad-1.png", - "width": 2752, - "height": 2064 - }, - { - "imageURL": "https://storage.googleapis.com/bemol/images/ipad/bemol-app-store-ipad-2.png", - "width": 2752, - "height": 2064 - }, - { - "imageURL": "https://storage.googleapis.com/bemol/images/ipad/bemol-app-store-ipad-3.png", - "width": 2752, - "height": 2064 - }, - { - "imageURL": "https://storage.googleapis.com/bemol/images/ipad/bemol-app-store-ipad-4.png", - "width": 2752, - "height": 2064 - }, - { - "imageURL": "https://storage.googleapis.com/bemol/images/ipad/bemol-app-store-ipad-5.png", - "width": 2752, - "height": 2064 - } - ] - }, - "versions": [ - { - "version": "2025.20.4", - "buildVersion": "25", - "date": "2025-05-15T19:20:00+02:00", - "size": 171000000, - "downloadURL": "https://storage.googleapis.com/bemol/adp/f4f0c47e-c15b-4ebb-aa76-2558fc7f0f32/manifest.json", - "localizedDescription": "This is the first public release of Bemol. Happy practicing 🎵.", - "minOSVersion": "18.2" - } - ], - "appPermissions": { - "entitlements": [], - "privacy": {} - } - } - ], - "news": [] -} diff --git a/Bemol/AppDelegate.swift b/iOS/AppDelegate.swift similarity index 99% rename from Bemol/AppDelegate.swift rename to iOS/AppDelegate.swift index 1f84062..84e8fdd 100644 --- a/Bemol/AppDelegate.swift +++ b/iOS/AppDelegate.swift @@ -46,4 +46,3 @@ class AppDelegate: UIResponder, UIApplicationDelegate { ) { } } - diff --git a/Bemol/Base.lproj/LaunchScreen.storyboard b/iOS/Base.lproj/LaunchScreen.storyboard similarity index 100% rename from Bemol/Base.lproj/LaunchScreen.storyboard rename to iOS/Base.lproj/LaunchScreen.storyboard diff --git a/iOS/Bemol-iOS-Info.plist b/iOS/Bemol-iOS-Info.plist new file mode 100644 index 0000000..0eb786d --- /dev/null +++ b/iOS/Bemol-iOS-Info.plist @@ -0,0 +1,23 @@ + + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + + diff --git a/Configurations/Debug.xcconfig b/iOS/Configurations/Debug.xcconfig similarity index 89% rename from Configurations/Debug.xcconfig rename to iOS/Configurations/Debug.xcconfig index ca9fd3c..9caeb5d 100644 --- a/Configurations/Debug.xcconfig +++ b/iOS/Configurations/Debug.xcconfig @@ -19,11 +19,10 @@ // Configuration settings file format documentation can be found at: // https://help.apple.com/xcode/#/dev745c5c974 +#include "Shared.xcconfig" #include "Signing.xcconfig" #include "Versioning.xcconfig" -GENERATE_INFOPLIST_FILE = YES +SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; -SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG - -ENABLE_TESTABILITY = YES +ENABLE_TESTABILITY = YES; diff --git a/Configurations/Release.xcconfig b/iOS/Configurations/Release.xcconfig similarity index 94% rename from Configurations/Release.xcconfig rename to iOS/Configurations/Release.xcconfig index e442046..36aa953 100644 --- a/Configurations/Release.xcconfig +++ b/iOS/Configurations/Release.xcconfig @@ -19,9 +19,8 @@ // Configuration settings file format documentation can be found at: // https://help.apple.com/xcode/#/dev745c5c974 +#include "Shared.xcconfig" #include "Signing.xcconfig" #include "Versioning.xcconfig" -GENERATE_INFOPLIST_FILE = YES - -ENABLE_TESTABILITY = NO +ENABLE_TESTABILITY = NO; diff --git a/iOS/Configurations/Shared.xcconfig b/iOS/Configurations/Shared.xcconfig new file mode 100644 index 0000000..1f23d9c --- /dev/null +++ b/iOS/Configurations/Shared.xcconfig @@ -0,0 +1,60 @@ +/// +/// Shared.xcconfig +/// Bemol +/// +/// Copyright 2025 Faiçal Tchirou +/// +/// Bemol is free software: you can redistribute it and/or modify it under the terms of +/// the GNU General Public License as published by the Free Software Foundation, either version 3 +/// of the License, or (at your option) any later version. +/// +/// Bemol is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +/// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +/// See the GNU General Public License for more details. +/// +/// You should have received a copy of the GNU General Public License along with Foobar. +/// If not, see . +/// + +// Configuration settings file format documentation can be found at: +// https://help.apple.com/xcode/#/dev745c5c974 + +SWIFT_VERSION = 6; + +SWIFT_STRICT_CONCURRENCY = complete; + +SWIFT_APPROACHABLE_CONCURRENCY = NO; + +GENERATE_INFOPLIST_FILE = YES; + +// https://docs.swift.org/compiler/documentation/diagnostics/upcoming-language-features +OTHER_SWIFT_FLAGS = $(inherited) \ + -enable-upcoming-feature NonisolatedNonsendingByDefault:migrate \ + -enable-upcoming-feature ExistentialAny:migrate \ + -enable-upcoming-feature MemberImportVisibility:migrate; + +IPHONEOS_DEPLOYMENT_TARGET = 18.2; + +TARGETED_DEVICE_FAMILY = 1,2; + +PRODUCT_NAME = Bemol; + +PRODUCT_BUNDLE_IDENTIFIER = com.tchirou.apps.bemol; + +INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight; + +INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = UIInterfaceOrientationLandscapeLeft \ + UIInterfaceOrientationLandscapeRight \ + UIInterfaceOrientationPortrait \ + UIInterfaceOrientationPortraitUpsideDown \ + UIInterfaceOrientationLandscapeLeft \ + UIInterfaceOrientationLandscapeRight; + +INFOPLIST_FILE = iOS/Bemol-iOS-Info.plist; + +SUPPORTS_MACCATALYST = NO; + +SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + +SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + diff --git a/Configurations/Versioning.xcconfig b/iOS/Configurations/Versioning.xcconfig similarity index 100% rename from Configurations/Versioning.xcconfig rename to iOS/Configurations/Versioning.xcconfig diff --git a/Bemol/SceneDelegate.swift b/iOS/SceneDelegate.swift similarity index 98% rename from Bemol/SceneDelegate.swift rename to iOS/SceneDelegate.swift index 2d24967..1d5bc5e 100644 --- a/Bemol/SceneDelegate.swift +++ b/iOS/SceneDelegate.swift @@ -17,8 +17,8 @@ /// import Foundation -import os import UIKit +import os class SceneDelegate: UIResponder, UIWindowSceneDelegate { // MARK: - @@ -59,7 +59,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { ) { guard let scene = (scene as? UIWindowScene) else { return } - window = UIWindow(frame: scene.screen.bounds) + window = UIWindow(windowScene: scene) window?.backgroundColor = .black window?.makeKeyAndVisible() diff --git a/Resources/TestFlightExportOptions.plist b/iOS/TestFlightExportOptions.plist similarity index 100% rename from Resources/TestFlightExportOptions.plist rename to iOS/TestFlightExportOptions.plist diff --git a/Bemol/en.lproj/LaunchScreen.strings b/iOS/en.lproj/LaunchScreen.strings similarity index 100% rename from Bemol/en.lproj/LaunchScreen.strings rename to iOS/en.lproj/LaunchScreen.strings diff --git a/Bemol/fr.lproj/LaunchScreen.strings b/iOS/fr.lproj/LaunchScreen.strings similarity index 100% rename from Bemol/fr.lproj/LaunchScreen.strings rename to iOS/fr.lproj/LaunchScreen.strings diff --git a/macOS/AppDelegate.swift b/macOS/AppDelegate.swift new file mode 100644 index 0000000..8dd508d --- /dev/null +++ b/macOS/AppDelegate.swift @@ -0,0 +1,387 @@ +/// +/// AppDelegate.swift +/// Bemol +/// +/// Copyright 2026 Faiçal Tchirou +/// +/// Bemol is free software: you can redistribute it and/or modify it under the terms of +/// the GNU General Public License as published by the Free Software Foundation, either version 3 +/// of the License, or (at your option) any later version. +/// +/// Bemol is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +/// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +/// See the GNU General Public License for more details. +/// +/// You should have received a copy of the GNU General Public License along with Foobar. +/// If not, see . + +import AppKit +import Cocoa +import os + +@main +class AppDelegate: NSObject, NSApplicationDelegate { + var window: NSWindow? + + // MARK: - Environment & Loop + + private lazy var environment = AppEnvironment( + notePlayer: MIDINotePlayer(), + practiceManager: CyclicPracticeManager( + storage: FileSessionStorage(fileManager: .default), + levelGenerator: DiatonicLevelGenerator(), + noteResolutionGenerator: DiatonicNoteResolutionGenerator(), + preferences: UserDefaults.standard + ), + tipProvider: OnboardingTipProvider(), + preferences: UserDefaults.standard, + logger: Logger() + ) + + private lazy var loop: AppLoop = { + let loop = AppLoop( + environment: environment, + initialState: AppState() + ) + loop.delegate = AppLoopDelegate( + didUpdateState: { [weak self] in self?.didUpdateState($0) } + ) + + return loop + }() + + private lazy var app = App( + environment: environment, + loop: loop + ) + + // MARK: - Toolbar Items + + private lazy var startStopButtonToolbarItem: NSToolbarItem = { + let item = NSToolbarItem(itemIdentifier: .startStopSessionButton) + item.toolTip = String(localized: "startSession") + item.image = NSImage(systemSymbolName: "play.fill", accessibilityDescription: nil) + item.isEnabled = true + item.action = #selector(startStopSessionButtonTapped) + item.target = self + + return item + }() + + private lazy var accuracyRingToolbarItem: NSToolbarItem = { + let item = NSToolbarItem(itemIdentifier: .accuracyButton) + item.toolTip = String(localized: "accuracyInLevel") + item.view = accuracyRing + item.isEnabled = true + + return item + }() + + private lazy var scoreLabelToolbarItem: NSToolbarItem = { + let item = NSToolbarItem(itemIdentifier: .scoreLabel) + item.view = scoreLabel + + return item + }() + + private lazy var scoreLabel: Label = { + let label = Label() + label.setUp() + label.font = .body + label.textAlignment = .center + label.numberOfLines = 1 + label.setContentHuggingPriority(.required, for: .vertical) + label.setContentCompressionResistancePriority(.required, for: .vertical) + + return label + }() + + private lazy var accuracyRing: AccuracyRing = { + let ring = AccuracyRing() + ring.setUp() + ring.strokeWidth = 3 + ring.widthAnchor.constraint(equalToConstant: 92).isActive = true + ring.heightAnchor.constraint(equalToConstant: 32).isActive = true + ring.addAction( + Action { [weak self] _ in self?.accuracyButtonTapped() }, + for: .touchUpInside + ) + + return ring + }() + + private lazy var isToolbarItemEnabled: [NSToolbarItem.Identifier: Bool] = [:] + + // MARK: - Main + + static func main() { + let delegate = AppDelegate() + NSApplication.shared.delegate = delegate + + _ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv) + } + + // MARK: - Lifecycle + + func applicationDidFinishLaunching(_ aNotification: Notification) { + let toolbar = NSToolbar() + toolbar.displayMode = .iconOnly + toolbar.delegate = self + + window = NSWindow(contentViewController: rootViewController()) + window?.appearance = NSAppearance(named: .vibrantDark) + window?.title = String(localized: "bemol") + window?.subtitle = String(localized: "earTraining") + window?.toolbar = toolbar + window?.isReleasedWhenClosed = true + window?.styleMask.insert(.borderless) + window?.styleMask.remove(.resizable) + window?.styleMask.insert(.unifiedTitleAndToolbar) + window?.makeKeyAndOrderFront(nil) + + loop.dispatch(.didLoad) + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + true + } + + func applicationWillTerminate(_ aNotification: Notification) { + } + + func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + false + } + + // MARK: - Private Helpers + + private func rootViewController() -> NSViewController { + app.view.setUp() + + let viewController = NSViewController() + viewController.view.frame = NSRect(x: 0, y: 0, width: 860, height: 340) + viewController.view.setUp() + viewController.view.backgroundStyle = .black + viewController.view.addSubview(app.view) + + NSLayoutConstraint.activate([ + app.view.leadingAnchor + .constraint(equalTo: viewController.view.safeAreaLayoutGuide.leadingAnchor), + app.view.topAnchor + .constraint(equalTo: viewController.view.safeAreaLayoutGuide.topAnchor), + app.view.trailingAnchor + .constraint(equalTo: viewController.view.safeAreaLayoutGuide.trailingAnchor), + app.view.bottomAnchor + .constraint(equalTo: viewController.view.safeAreaLayoutGuide.bottomAnchor), + ]) + + return viewController + } +} + +// MARK: - AppLoopDelegate + +extension AppDelegate { + func didUpdateState(_ state: AppState) { + app.setState(state) + window?.subtitle = state.level == nil ? String(localized: "earTraining") : state.level!.title + updateToolbar(state.mainScreenState.navBarState) + } + + func updateToolbar(_ state: NavBarState) { + isToolbarItemEnabled[.firstLevelButton] = state.isHomeButtonEnabled + isToolbarItemEnabled[.randomLevelButton] = state.isRandomButtonEnabled + isToolbarItemEnabled[.previousLevelButton] = state.isPreviousButtonEnabled + isToolbarItemEnabled[.nextLevelButton] = state.isNextButtonEnabled + isToolbarItemEnabled[.configureLevelButton] = state.isConfigureButtonEnabled + isToolbarItemEnabled[.startStopSessionButton] = state.isStartStopButtonEnabled + isToolbarItemEnabled[.repeatButton] = state.isRepeatButtonEnabled + isToolbarItemEnabled[.accuracyButton] = state.isAccuracyRingEnabled + + startStopButtonToolbarItem.image = NSImage( + systemSymbolName: state.startStopButtonMode == .start ? "play.fill" : "stop.fill", + accessibilityDescription: nil + ) + startStopButtonToolbarItem.toolTip = state.startStopButtonMode == .start + ? String(localized: "startSession") + : String(localized: "stopSession") + + scoreLabel.attributedText = state.scoreText.flatMap { NSAttributedString($0) } + scoreLabel.isHidden = state.isScoreLabelHidden + scoreLabelToolbarItem.toolTip = state.scoreAccessibilityText + + accuracyRing.accuracy = state.accuracy + accuracyRing.isEnabled = state.isAccuracyRingEnabled + accuracyRingToolbarItem.toolTip = switch (state.startStopButtonMode, state.isAccuracyRingEnabled) { + case (.start, true): + String(localized: "accuracyInLevelWithCTA") + case (.start, false): + String(localized: "accuracyInLevel") + case (.stop, true): + String(localized: "accuracyInSessionWithCTA") + case (.stop, false): + String(localized: "accuracyInSession") + } + + NSApplication.shared.setWindowsNeedUpdate(true) + } +} + +// MARK: - NSToolbarDelegate + +extension AppDelegate: NSToolbarDelegate { + func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + return [ + .levelSelectionGroup, + .levelNavigationGroup, + .practiceSessionGroup, + .scoreLabel, + .accuracyButton, + ] + } + + func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + return [ + .levelSelectionGroup, + .levelNavigationGroup, + .practiceSessionGroup, + .scoreLabel, + .accuracyButton, + ] + } + + func toolbar( + _ toolbar: NSToolbar, + itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, + willBeInsertedIntoToolbar flag: Bool + ) -> NSToolbarItem? { + switch itemIdentifier { + case .levelSelectionGroup: + let firstLevelButtonItem = NSToolbarItem(itemIdentifier: .firstLevelButton) + firstLevelButtonItem.toolTip = String(localized: "goToCMajor") + firstLevelButtonItem.image = NSImage(systemSymbolName: "c.circle.fill", accessibilityDescription: nil) + firstLevelButtonItem.action = #selector(firstLevelButtonTapped) + firstLevelButtonItem.target = self + firstLevelButtonItem.isEnabled = isToolbarItemEnabled[.firstLevelButton] ?? false + + let randomLevelButtonItem = NSToolbarItem(itemIdentifier: .randomLevelButton) + randomLevelButtonItem.toolTip = String(localized: "goToRandomLevel") + randomLevelButtonItem.image = NSImage(systemSymbolName: "shuffle.circle.fill", accessibilityDescription: nil) + randomLevelButtonItem.action = #selector(randomLevelButtonTapped) + randomLevelButtonItem.target = self + randomLevelButtonItem.isEnabled = true + + let group = NSToolbarItemGroup(itemIdentifier: itemIdentifier) + group.subitems = [firstLevelButtonItem, randomLevelButtonItem] + + return group + + case .levelNavigationGroup: + let previousLevelButtonItem = NSToolbarItem(itemIdentifier: .previousLevelButton) + previousLevelButtonItem.toolTip = String(localized: "goToPreviousLevel") + previousLevelButtonItem.image = NSImage(systemSymbolName: "chevron.left", accessibilityDescription: nil) + previousLevelButtonItem.action = #selector(previousLevelButtonTapped) + previousLevelButtonItem.target = self + previousLevelButtonItem.isEnabled = true + + let nextLevelButtonItem = NSToolbarItem(itemIdentifier: .nextLevelButton) + nextLevelButtonItem.toolTip = String(localized: "goToNextLevel") + nextLevelButtonItem.image = NSImage(systemSymbolName: "chevron.right", accessibilityDescription: nil) + nextLevelButtonItem.isEnabled = true + nextLevelButtonItem.action = #selector(nextLevelButtonTapped) + nextLevelButtonItem.target = self + + let group = NSToolbarItemGroup(itemIdentifier: itemIdentifier) + group.subitems = [previousLevelButtonItem, nextLevelButtonItem] + + return group + + case .practiceSessionGroup: + let configureLevelButtonItem = NSToolbarItem(itemIdentifier: .configureLevelButton) + configureLevelButtonItem.toolTip = String(localized: "configureLevel") + configureLevelButtonItem.image = NSImage(systemSymbolName: "slider.vertical.3", accessibilityDescription: nil) + configureLevelButtonItem.isEnabled = true + configureLevelButtonItem.action = #selector(configureLevelButtonTapped) + configureLevelButtonItem.target = self + + let repeatButtonItem = NSToolbarItem(itemIdentifier: .repeatButton) + repeatButtonItem.toolTip = String(localized: "replayQuestion") + repeatButtonItem.image = NSImage(systemSymbolName: "repeat", accessibilityDescription: nil) + repeatButtonItem.isEnabled = true + repeatButtonItem.action = #selector(repeatButtonTapped) + repeatButtonItem.target = self + + let group = NSToolbarItemGroup(itemIdentifier: itemIdentifier) + group.subitems = [configureLevelButtonItem, startStopButtonToolbarItem, repeatButtonItem] + + return group + + case .scoreLabel: + return scoreLabelToolbarItem + + case .accuracyButton: + return accuracyRingToolbarItem + + default: + return nil + } + } +} + +// MARK: - Toolbar Actions + +extension AppDelegate { + @objc private func validateToolbarItem(_ item: NSToolbarItem) -> Bool { + isToolbarItemEnabled[item.itemIdentifier] ?? false + } + + @objc private func firstLevelButtonTapped() { + loop.dispatch(.didPressHomeButton) + } + + @objc private func randomLevelButtonTapped() { + loop.dispatch(.didPressRandomButton) + } + + @objc private func previousLevelButtonTapped() { + loop.dispatch(.didPressPreviousLevelButton) + } + + @objc private func nextLevelButtonTapped() { + loop.dispatch(.didPressNextLevelButton) + } + + @objc private func configureLevelButtonTapped() { + loop.dispatch(.didPressConfigureLevelButton) + } + + @objc private func startStopSessionButtonTapped() { + loop.dispatch(.didPressStartStopLevelButton) + } + + @objc private func repeatButtonTapped() { + loop.dispatch(.didPressRepeatQuestionButton) + } + + @objc private func accuracyButtonTapped() { + loop.dispatch(.didPressAccuracyRing) + } +} + +// MARK: - Toolbar Identifiers + +private extension NSToolbarItem.Identifier { + static let levelSelectionGroup = NSToolbarItem.Identifier(rawValue: "levelSelectonGroup") + static let levelNavigationGroup = NSToolbarItem.Identifier(rawValue: "levelNavigationGroup") + static let practiceSessionGroup = NSToolbarItem.Identifier(rawValue: "practiceSessionGroup") + + static let firstLevelButton = NSToolbarItem.Identifier(rawValue: "firstLevelButton") + static let randomLevelButton = NSToolbarItem.Identifier(rawValue: "randomLevelButton") + static let previousLevelButton = NSToolbarItem.Identifier(rawValue: "previousLevelButton") + static let nextLevelButton = NSToolbarItem.Identifier(rawValue: "nextLevelButton") + static let configureLevelButton = NSToolbarItem.Identifier(rawValue: "configureLevelButton") + static let startStopSessionButton = NSToolbarItem.Identifier(rawValue: "startStopSessionButton") + static let repeatButton = NSToolbarItem.Identifier(rawValue: "repeatButton") + static let scoreLabel = NSToolbarItem.Identifier(rawValue: "scoreLabel") + static let accuracyButton = NSToolbarItem.Identifier(rawValue: "accuracyButton") +} diff --git a/macOS/Bemol-macOS-Info.plist b/macOS/Bemol-macOS-Info.plist new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/macOS/Bemol-macOS-Info.plist @@ -0,0 +1,5 @@ + + + + + diff --git a/macOS/Configurations/Debug.xcconfig b/macOS/Configurations/Debug.xcconfig new file mode 100644 index 0000000..345c698 --- /dev/null +++ b/macOS/Configurations/Debug.xcconfig @@ -0,0 +1,30 @@ +/// +/// Debug.xcconfig +/// Bemol +/// +/// Copyright 2026 Faiçal Tchirou +/// +/// Bemol is free software: you can redistribute it and/or modify it under the terms of +/// the GNU General Public License as published by the Free Software Foundation, either version 3 +/// of the License, or (at your option) any later version. +/// +/// Bemol is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +/// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +/// See the GNU General Public License for more details. +/// +/// You should have received a copy of the GNU General Public License along with Foobar. +/// If not, see . +/// + +// Configuration settings file format documentation can be found at: +// https://help.apple.com/xcode/#/dev745c5c974 + +#include "Shared.xcconfig" +#include "Signing.xcconfig" +#include "Versioning.xcconfig" + +GENERATE_INFOPLIST_FILE = YES; + +SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + +ENABLE_TESTABILITY = YES; diff --git a/macOS/Configurations/Release.xcconfig b/macOS/Configurations/Release.xcconfig new file mode 100644 index 0000000..5c66191 --- /dev/null +++ b/macOS/Configurations/Release.xcconfig @@ -0,0 +1,26 @@ +/// +/// Release.xcconfig +/// Bemol +/// +/// Copyright 2026 Faiçal Tchirou +/// +/// Bemol is free software: you can redistribute it and/or modify it under the terms of +/// the GNU General Public License as published by the Free Software Foundation, either version 3 +/// of the License, or (at your option) any later version. +/// +/// Bemol is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +/// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +/// See the GNU General Public License for more details. +/// +/// You should have received a copy of the GNU General Public License along with Foobar. +/// If not, see . +/// + +// Configuration settings file format documentation can be found at: +// https://help.apple.com/xcode/#/dev745c5c974 + +#include "Shared.xcconfig" +#include "Signing.xcconfig" +#include "Versioning.xcconfig" + +ENABLE_TESTABILITY = NO; diff --git a/macOS/Configurations/Shared.xcconfig b/macOS/Configurations/Shared.xcconfig new file mode 100644 index 0000000..11c632e --- /dev/null +++ b/macOS/Configurations/Shared.xcconfig @@ -0,0 +1,42 @@ +/// +/// Debug.xcconfig +/// Bemol +/// +/// Copyright 2026 Faiçal Tchirou +/// +/// Bemol is free software: you can redistribute it and/or modify it under the terms of +/// the GNU General Public License as published by the Free Software Foundation, either version 3 +/// of the License, or (at your option) any later version. +/// +/// Bemol is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +/// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +/// See the GNU General Public License for more details. +/// +/// You should have received a copy of the GNU General Public License along with Foobar. +/// If not, see . +/// + +// Configuration settings file format documentation can be found at: +// https://help.apple.com/xcode/#/dev745c5c974 + +SWIFT_VERSION = 6; + +SWIFT_STRICT_CONCURRENCY = complete; + +SWIFT_APPROACHABLE_CONCURRENCY = NO; + +GENERATE_INFOPLIST_FILE = YES; + +INFOPLIST_FILE = macOS/Bemol-macOS-Info.plist; + +// https://docs.swift.org/compiler/documentation/diagnostics/upcoming-language-features +OTHER_SWIFT_FLAGS = $(inherited) \ + -enable-upcoming-feature NonisolatedNonsendingByDefault:migrate \ + -enable-upcoming-feature ExistentialAny:migrate \ + -enable-upcoming-feature MemberImportVisibility:migrate; + +MACOS_DEPLOYMENT_TARGET = 26.4; + +PRODUCT_NAME = Bemol; + +PRODUCT_BUNDLE_IDENTIFIER = com.tchirou.apps.bemol.macos; diff --git a/macOS/Configurations/Versioning.xcconfig b/macOS/Configurations/Versioning.xcconfig new file mode 100644 index 0000000..e1e5e58 --- /dev/null +++ b/macOS/Configurations/Versioning.xcconfig @@ -0,0 +1,24 @@ +/// +/// Versioning.xcconfig +/// Bemol +/// +/// Copyright 2026 Faiçal Tchirou +/// +/// Bemol is free software: you can redistribute it and/or modify it under the terms of +/// the GNU General Public License as published by the Free Software Foundation, either version 3 +/// of the License, or (at your option) any later version. +/// +/// Bemol is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; +/// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +/// See the GNU General Public License for more details. +/// +/// You should have received a copy of the GNU General Public License along with Foobar. +/// If not, see . +/// + +// Configuration settings file format documentation can be found at: +// https://help.apple.com/xcode/#/dev745c5c974 + +CURRENT_PROJECT_VERSION = 27 + +MARKETING_VERSION = 2026.26.1 diff --git a/macOS/ExportOptions.plist b/macOS/ExportOptions.plist new file mode 100644 index 0000000..713b1a5 --- /dev/null +++ b/macOS/ExportOptions.plist @@ -0,0 +1,10 @@ + + + + + manageAppVersionAndBuildNumber + + method + mac-application + + diff --git a/www/404.html b/www/404.html new file mode 100644 index 0000000..03994a6 --- /dev/null +++ b/www/404.html @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + Bemol – Develop relative pitch + + +
+
+

Oops!

+
+
+
Happy practicing!
+ + \ No newline at end of file diff --git a/www/assets/android-chrome-192x192.png b/www/assets/android-chrome-192x192.png new file mode 100644 index 0000000..b26e222 Binary files /dev/null and b/www/assets/android-chrome-192x192.png differ diff --git a/www/assets/android-chrome-512x512.png b/www/assets/android-chrome-512x512.png new file mode 100644 index 0000000..7234616 Binary files /dev/null and b/www/assets/android-chrome-512x512.png differ diff --git a/www/assets/apple-touch-icon-114x114.png b/www/assets/apple-touch-icon-114x114.png new file mode 100644 index 0000000..a481592 Binary files /dev/null and b/www/assets/apple-touch-icon-114x114.png differ diff --git a/www/assets/apple-touch-icon-120x120.png b/www/assets/apple-touch-icon-120x120.png new file mode 100644 index 0000000..cc5295f Binary files /dev/null and b/www/assets/apple-touch-icon-120x120.png differ diff --git a/www/assets/apple-touch-icon-144x144.png b/www/assets/apple-touch-icon-144x144.png new file mode 100644 index 0000000..9a7573d Binary files /dev/null and b/www/assets/apple-touch-icon-144x144.png differ diff --git a/www/assets/apple-touch-icon-152x152.png b/www/assets/apple-touch-icon-152x152.png new file mode 100644 index 0000000..8484019 Binary files /dev/null and b/www/assets/apple-touch-icon-152x152.png differ diff --git a/www/assets/apple-touch-icon-167x167.png b/www/assets/apple-touch-icon-167x167.png new file mode 100644 index 0000000..00bc678 Binary files /dev/null and b/www/assets/apple-touch-icon-167x167.png differ diff --git a/www/assets/apple-touch-icon-180x180.png b/www/assets/apple-touch-icon-180x180.png new file mode 100644 index 0000000..8505d96 Binary files /dev/null and b/www/assets/apple-touch-icon-180x180.png differ diff --git a/www/assets/apple-touch-icon-57x57.png b/www/assets/apple-touch-icon-57x57.png new file mode 100644 index 0000000..6d6da04 Binary files /dev/null and b/www/assets/apple-touch-icon-57x57.png differ diff --git a/www/assets/apple-touch-icon-60x60.png b/www/assets/apple-touch-icon-60x60.png new file mode 100644 index 0000000..4dd115e Binary files /dev/null and b/www/assets/apple-touch-icon-60x60.png differ diff --git a/www/assets/apple-touch-icon-68x68.png b/www/assets/apple-touch-icon-68x68.png new file mode 100644 index 0000000..430b9c3 Binary files /dev/null and b/www/assets/apple-touch-icon-68x68.png differ diff --git a/www/assets/apple-touch-icon-72x72.png b/www/assets/apple-touch-icon-72x72.png new file mode 100644 index 0000000..6df59aa Binary files /dev/null and b/www/assets/apple-touch-icon-72x72.png differ diff --git a/www/assets/apple-touch-icon-76x76.png b/www/assets/apple-touch-icon-76x76.png new file mode 100644 index 0000000..858f97e Binary files /dev/null and b/www/assets/apple-touch-icon-76x76.png differ diff --git a/www/assets/bemol-github.png b/www/assets/bemol-github.png new file mode 100644 index 0000000..7e7557a Binary files /dev/null and b/www/assets/bemol-github.png differ diff --git a/www/assets/bemol.png b/www/assets/bemol.png new file mode 100644 index 0000000..a757dea Binary files /dev/null and b/www/assets/bemol.png differ diff --git a/www/assets/browserconfig.xml b/www/assets/browserconfig.xml new file mode 100644 index 0000000..b3930d0 --- /dev/null +++ b/www/assets/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #da532c + + + diff --git a/www/assets/favicon-16x16.png b/www/assets/favicon-16x16.png new file mode 100644 index 0000000..d2c8527 Binary files /dev/null and b/www/assets/favicon-16x16.png differ diff --git a/www/assets/favicon-32x32.png b/www/assets/favicon-32x32.png new file mode 100644 index 0000000..ef387f1 Binary files /dev/null and b/www/assets/favicon-32x32.png differ diff --git a/www/assets/favicon-48x48.png b/www/assets/favicon-48x48.png new file mode 100644 index 0000000..d19e052 Binary files /dev/null and b/www/assets/favicon-48x48.png differ diff --git a/www/assets/favicon-64x64.png b/www/assets/favicon-64x64.png new file mode 100644 index 0000000..eea6963 Binary files /dev/null and b/www/assets/favicon-64x64.png differ diff --git a/www/assets/favicon-96x96.png b/www/assets/favicon-96x96.png new file mode 100644 index 0000000..882053c Binary files /dev/null and b/www/assets/favicon-96x96.png differ diff --git a/www/assets/favicon-meta-tags.html b/www/assets/favicon-meta-tags.html new file mode 100644 index 0000000..cc79bfa --- /dev/null +++ b/www/assets/favicon-meta-tags.html @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/assets/favicon.ico b/www/assets/favicon.ico new file mode 100644 index 0000000..67f2dd4 Binary files /dev/null and b/www/assets/favicon.ico differ diff --git a/www/assets/mstile-144x144.png b/www/assets/mstile-144x144.png new file mode 100644 index 0000000..fb1f990 Binary files /dev/null and b/www/assets/mstile-144x144.png differ diff --git a/www/assets/mstile-150x150.png b/www/assets/mstile-150x150.png new file mode 100644 index 0000000..4b53c06 Binary files /dev/null and b/www/assets/mstile-150x150.png differ diff --git a/www/assets/mstile-310x310.png b/www/assets/mstile-310x310.png new file mode 100644 index 0000000..89b03a7 Binary files /dev/null and b/www/assets/mstile-310x310.png differ diff --git a/www/assets/mstile-70x70.png b/www/assets/mstile-70x70.png new file mode 100644 index 0000000..608c7f7 Binary files /dev/null and b/www/assets/mstile-70x70.png differ diff --git a/www/assets/site.webmanifest b/www/assets/site.webmanifest new file mode 100644 index 0000000..cd36c88 --- /dev/null +++ b/www/assets/site.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "Bemol", + "short_name": "Bemol", + "description": "Bemol - Develop relative pitch", + "start_url": "/", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable any" + } + ] +} diff --git a/www/index.html b/www/index.html new file mode 100644 index 0000000..2e5727b --- /dev/null +++ b/www/index.html @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + Bemol – Develop relative pitch + + +
+
+

Bemol

+

A free and open-source ear training app that helps music hobbyists and music students train and develop relative pitch, the ability to recognize a played musical note in a given tonal context.

+
+
+ +
+ +
+
+
+

How it works

+

Bemol helps you learn the character of each musical note by getting you to internalize how each note relates to its nearest tonic in a given key.

+ In each practice session, you are prompted to identify a series of played notes. Before each note, Bemol plays a I - IV - V - I cadence to establish the key. After you have identified the note, a short melody is played to resolve the note to the nearest tonic. Listening to this final resolution helps cement the relationship between the note and the tonic in your mind's ear.

+ As this process repeats, you start to internalize the character of each note in a given tonal context. This helps develop relative pitch and eventually you will have a much easier time recognizing any note as long as a tonal context is clearly established.

+ This ear training method was first described and implemented by Alain Benbassat in his free Functional Ear Trainer desktop app. Bemol is simply a native, free and open-source implementation of the method for macOS.

+
+
+
+
+

Practice, practice, practice

+

Bemol is organized in a series of progressive levels. The first level consists only of the first 4 notes in the key of C major. Once you have at least 90% accuracy in this level, you can move to the next one where you will practice the next 4 notes. Then, you move on to practice recognizing notes from the entire scale.

+ Once the entire scale is mastered, the next levels will progressively introduce chromatic notes. When you're comfortable recognizing both diatonic and chromatic notes in the key of C major, a new key is introduced beginning again with the first 4 notes. The whole process repeats along the circle of fourths until you have learned to recognize all notes in all 12 major and 12 minor keys.

+
+
+
+
+

Reality check

+

No app out there (including Bemol) on its own will make you develop your ear. Actually sitting down at your instrument practicing, learning, and making music over a very long period of time will. Use Bemol as a complement to a consistent practice routine and as a tool for measuring how far you've come.

+
+
+
+ + + \ No newline at end of file diff --git a/www/robots.txt b/www/robots.txt new file mode 100644 index 0000000..5f12501 --- /dev/null +++ b/www/robots.txt @@ -0,0 +1 @@ +# Allow all diff --git a/www/styles/main.css b/www/styles/main.css new file mode 100644 index 0000000..2f92b94 --- /dev/null +++ b/www/styles/main.css @@ -0,0 +1,120 @@ +* { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +:root { + --background-color: #fefef5; + --primary-color: #c38003; + --primary-color-hover: #e9a11c; + --primary-color-pressed: #cf8908; + --link-decoration-color: #e5c790; + --text-color: #333333; + --footer-text-color: rgba(53, 52, 52, 0.25); +} + +html, body { + font-family: -apple-system, "BlinkMacSystemFont", "Avenir", "Avenir Next", "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; + font-size: 100%; + background-color: var(--background-color); + text-align: left; + color: var(--text-color); + height: 100%; + width: 100%; + margin: inherit; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +body, footer { + width: 80%; + margin: auto; +} + +main { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + justify-content: center; + padding: 8%; +} + +section { + margin-top: 24px; +} + +.row { + width: 100%; + display: grid; + place-items: center; +} + +.title { + font-family: Borel, 'Momo Signature', -apple-system, "BlinkMacSystemFont", "Avenir", "Avenir Next", "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; + font-size: 68px; + color: var(--primary-color); + margin-bottom: 0; + line-height: 1em; +} + +.tagline { + margin-top: 0; + width: 50%; + text-align: center; + font-size: 15px; +} + +h2 { + font-family: Borel, 'Momo Signature', -apple-system, "BlinkMacSystemFont", "Avenir", "Avenir Next", "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; + font-size: 32px; + line-height: 1em; + margin-bottom: 0; +} + +.showcase { + width: 85%; + max-width: 972px; +} + +p { + line-height: 1.2em; +} + +a { + color: var(--primary-color); + text-decoration: underline dashed; + font-weight: 500; +} + +a:hover { + color: var(--primary-color-hover); +} + +a:active { + color: var(--primary-color-pressed); +} + +.highlight { + font-style: italic; + text-decoration: underline; + font-weight: bold; +} + +.cta { + font-weight: bold; + text-decoration: underline; +} + +footer { + padding: 48px; + text-align: center; + color: var(--footer-text-color); +}