diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..a0cbfc4 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,119 @@ +name: Build and Deploy + +on: + push: + branches: + - main + pull_request: + branches: + - main + types: + - opened + - synchronize + - reopened + - ready_for_review + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }} + APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} + APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY }} + MATCH_KEYCHAIN_NAME: ${{ vars.MATCH_KEYCHAIN_NAME }} + MATCH_KEYCHAIN_PASSWORD: ${{ secrets.MATCH_KEYCHAIN_PASSWORD }} + MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} + FASTLANE_ENV: 'stg' + XCODE_VERSION: '26.5' + +jobs: + build: + runs-on: macos-26 + # Block execution in template repositories and skip draft PRs + if: github.event.pull_request.draft == false && !contains(github.repository, 'template') + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + + - name: Select Xcode version + run: sudo xcode-select -s '/Applications/Xcode_${{ env.XCODE_VERSION }}.app' + + - name: Set up SSH key + run: | + set -e + mkdir -p ~/.ssh + echo "${{ secrets.HOPPSEN_BOT_SSH_KEY }}" > ~/.ssh/github_actions + chmod 0600 ~/.ssh/github_actions + ssh-add ~/.ssh/github_actions + + # Disable Strict Host Key Checking in SSH + printf "Host *\n\tStrictHostKeyChecking no" > ~/.ssh/config + chmod 400 ~/.ssh/config + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3.0' + bundler-cache: true + + - name: Cache SPM + id: cache_spm + uses: actions/cache@v4 + with: + path: fastlane/sourcePackages + key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }} + restore-keys: | + ${{ runner.os }}-spm- + + - name: Cache Derived Data + id: cache_derived_data + uses: actions/cache@v4 + with: + path: fastlane/derivedData + key: ${{ runner.os }}-xcode-${{ env.XCODE_VERSION }}-derived-data-${{ hashFiles('**/Package.resolved', '**/Template.xcodeproj') }} + restore-keys: | + ${{ runner.os }}-xcode-${{ env.XCODE_VERSION }}-derived-data- + + - name: Update version number + run: bundle exec fastlane updateVersion --env $FASTLANE_ENV + + - name: Build + run: bundle exec fastlane build --env $FASTLANE_ENV + env: + SPM_CACHE_RESTORED: ${{ steps.cache_spm.outputs.cache-hit }} + FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 5 + FASTLANE_XCODEBUILD_SETTINGS_RETRIES: 10 + + - name: Deploy to TestFlight + run: bundle exec fastlane deploy changelog:${{ github.head_ref || github.ref_name }} --env $FASTLANE_ENV + + - name: Post TestFlight build number to Pull Request + uses: actions/github-script@v7 + if: github.event.pull_request + with: + github-token: ${{ secrets.HOPPSEN_BOT_TOKEN }} + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `**TestFlight build ${{ env.VERSION_NUMBER }} (${{ env.BUILD_NUMBER }})** has been successfully completed and is now processing. The build should be available shortly. πŸš€πŸ“±` + }) + + - name: Upload output files + uses: actions/upload-artifact@v4 + with: + name: outputs + path: | + culprits.txt + fastlane/output/*.ipa + fastlane/output/*.dSYM.zip + retention-days: 7 + + - name: Upload gym build log + if: failure() + uses: actions/upload-artifact@v4 + with: + name: gym-log + path: /Users/runner/Library/Logs/gym/*.log diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index ef95abf..c77f0bd 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -18,7 +18,7 @@ on: jobs: SwiftLint: runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 30 steps: - uses: actions/checkout@v4 - name: Read version from Mintfile diff --git a/.github/workflows/releaseMonitor.yml b/.github/workflows/releaseMonitor.yml new file mode 100644 index 0000000..676edee --- /dev/null +++ b/.github/workflows/releaseMonitor.yml @@ -0,0 +1,132 @@ +name: Release Monitor + +on: + # Disabled until the first release is created + # schedule: + # - cron: '0 * * * *' # Run every hour + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +env: + APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }} + APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} + APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY }} + APP_STORE_APP_ID: ${{ vars.APP_STORE_APP_ID }} # <- CHANGE this to the app ID of the app you want to monitor + +jobs: + app-store-release-monitor: + runs-on: ubuntu-latest + # Block execution in template repositories + if: ${{ !contains(github.repository, 'template') }} + steps: + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install jsonwebtoken + run: npm install jsonwebtoken + + - name: Check App Store Version + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.HOPPSEN_BOT_TOKEN }} + script: | + const jwt = require('./node_modules/jsonwebtoken'); + + function generateToken() { + const privateKey = process.env.APP_STORE_CONNECT_API_KEY_KEY.replace(/\\n/g, '\n'); + return jwt.sign({ + iss: process.env.APP_STORE_CONNECT_API_KEY_ISSUER_ID, + exp: Math.floor(Date.now() / 1000) + (20 * 60), // 20 minutes + aud: 'appstoreconnect-v1' + }, privateKey, { + algorithm: 'ES256', + header: { + kid: process.env.APP_STORE_CONNECT_API_KEY_KEY_ID + } + }); + } + + async function getLatestAppStoreVersion() { + const token = generateToken(); + + console.log('::group::Version API Response'); + const versionResponse = await fetch( + `https://api.appstoreconnect.apple.com/v1/apps/${process.env.APP_STORE_APP_ID}/appStoreVersions?filter[platform]=IOS&filter[appStoreState]=READY_FOR_SALE`, + { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + } + ); + const versionData = await versionResponse.json(); + if (!versionResponse.ok) { + throw new Error(`Version API request failed: ${JSON.stringify(versionData)}`); + } + + console.log(JSON.stringify(versionData.data[0], null, 2)); + console.log('::endgroup::'); + + if (!versionData.data || !versionData.data[0]) { + throw new Error('No version data found'); + } + + // Get the build URL from relationships + const buildUrl = versionData.data[0].relationships.build.links.related; + + // Get the build info + const buildResponse = await fetch(buildUrl, { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + const buildData = await buildResponse.json(); + if (!buildResponse.ok) { + throw new Error(`Build API request failed: ${JSON.stringify(buildData)}`); + } + + console.log('::group::Build API Response'); + console.log(JSON.stringify(buildData, null, 2)); + console.log('::endgroup::'); + + return { + version: versionData.data[0].attributes.versionString, + buildNumber: buildData.data.attributes.version + }; + } + + try { + const appInfo = await getLatestAppStoreVersion(); + const tagName = `${appInfo.version}-${appInfo.buildNumber}`; + + // Check if release already exists + try { + await github.rest.repos.getReleaseByTag({ + owner: context.repo.owner, + repo: context.repo.repo, + tag: tagName + }); + console.log(`Release ${tagName} already exists, skipping creation`); + return; + } catch (e) { + // Release doesn't exist, create it + console.log(`Creating new release for ${tagName}`); + + // Create release with auto-generated notes + await github.rest.repos.createRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + tag_name: tagName, + name: `${tagName}`, + generate_release_notes: true + }); + + console.log(`Successfully created release for version ${tagName}`); + } + } catch (error) { + core.setFailed(`Release creation failed: ${error.message}`); + } diff --git a/.github/workflows/releasing.yml b/.github/workflows/releasing.yml new file mode 100644 index 0000000..b63a780 --- /dev/null +++ b/.github/workflows/releasing.yml @@ -0,0 +1,119 @@ +name: Releasing + +on: + # schedule: + # - cron: '0 16 * * FRI' # At 20:00 on Friday Dubai time + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + inputs: + project: + description: 'Select the project to point to.' + required: true + default: 'prd' + type: choice + options: + - stg + - prd + patch_number: + description: 'Specify the patch number to be appended to the version number in the following format: .. Only needed when releasing more than one version in a week.' + required: false + default: '0' + +env: + APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }} + APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} + APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY }} + MATCH_KEYCHAIN_NAME: ${{ vars.MATCH_KEYCHAIN_NAME }} + MATCH_KEYCHAIN_PASSWORD: ${{ secrets.MATCH_KEYCHAIN_PASSWORD }} + MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} + FASTLANE_ENV: ${{ inputs.project || 'prd' }} + PATCH_NUMBER: ${{ inputs.patch_number || '0' }} + XCODE_VERSION: '26.5' + +jobs: + build: + runs-on: macos-26 + # Block execution in template repositories + if: ${{ !contains(github.repository, 'template') }} + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + + - name: Select Xcode version + run: sudo xcode-select -s '/Applications/Xcode_${{ env.XCODE_VERSION }}.app' + + - name: Set up SSH key + run: | + set -e + mkdir -p ~/.ssh + echo "${{ secrets.HOPPSEN_BOT_SSH_KEY }}" > ~/.ssh/github_actions + chmod 0600 ~/.ssh/github_actions + ssh-add ~/.ssh/github_actions + + # Disable Strict Host Key Checking in SSH + printf "Host *\n\tStrictHostKeyChecking no" > ~/.ssh/config + chmod 400 ~/.ssh/config + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3.0' + bundler-cache: true + + - name: Cache SPM + id: cache_spm + uses: actions/cache@v4 + with: + path: fastlane/sourcePackages + key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }} + restore-keys: | + ${{ runner.os }}-spm- + + - name: Cache Derived Data + id: cache_derived_data + uses: actions/cache@v4 + with: + path: fastlane/derivedData + key: ${{ runner.os }}-xcode-${{ env.XCODE_VERSION }}-derived-data-${{ hashFiles('**/Package.resolved', '**/Template.xcodeproj') }} + restore-keys: | + ${{ runner.os }}-xcode-${{ env.XCODE_VERSION }}-derived-data- + + - name: Update version number + run: bundle exec fastlane updateVersion patch_number:$PATCH_NUMBER --env $FASTLANE_ENV + + - name: Build + run: bundle exec fastlane build --env $FASTLANE_ENV + env: + SPM_CACHE_RESTORED: ${{ steps.cache_spm.outputs.cache-hit }} + FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 5 + FASTLANE_XCODEBUILD_SETTINGS_RETRIES: 10 + + - name: Deploy to TestFlight + run: bundle exec fastlane deploy changelog:${{ github.head_ref || github.ref_name }} --env $FASTLANE_ENV + + - name: Create Git Tag + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.HOPPSEN_BOT_TOKEN }} + script: | + const tagName = `${process.env.VERSION_NUMBER}-${process.env.BUILD_NUMBER}`; + await github.rest.git.createRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `refs/tags/${tagName}`, + sha: context.sha + }); + + - name: Upload metadata to App Store Connect + run: bundle exec fastlane upload_metadata version_number:${{ env.VERSION_NUMBER }} build_number:${{ env.BUILD_NUMBER }} --env $FASTLANE_ENV + + - name: Upload output files + uses: actions/upload-artifact@v4 + with: + name: outputs + path: | + culprits.txt + fastlane/output/*.ipa + fastlane/output/*.dSYM.zip + retention-days: 14 diff --git a/.swiftformat b/.swiftformat index 276393c..11ccdc3 100644 --- a/.swiftformat +++ b/.swiftformat @@ -1,10 +1,13 @@ # file options ---exclude Pods,**/.build,**/Package.swift,vendor/bundle,scripts,fastlane,**/L10n.swift,**/Assets.swift,**/*.generated.swift +--exclude Pods,**/.build,**/Package.swift,vendor/bundle,scripts,fastlane,**/L10n.swift,**/Assets.swift,**/*.generated.swift,**/Backport.swift # format options --swiftversion 5.5 +## fileHeader +--header strip + ## elseOnSameLine --guardelse same-line diff --git a/.swiftlint.yml b/.swiftlint.yml index 52a30c2..fc9a6e3 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -6,6 +6,10 @@ excluded: # paths to ignore during linting. Takes precedence over `included`. - BuildTools - fastlane - templates + - Template/Assets/Assets.swift +large_tuple: + warning: 3 + error: 5 line_length: 160 identifier_name: allowed_symbols: diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6630e18 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,264 @@ +# CLAUDE.md + +Guidance for Claude Code and engineers working in this repository β€” a public Xcode template +([hoppsen/xcode-template](https://github.com/hoppsen/xcode-template)) and every app created from it. +New apps are created via `bundle exec fastlane rename new_name:`, which rewrites the +placeholder project name everywhere β€” including in this file β€” so everything below applies 1:1 to +renamed apps. + +- This file is the single source of truth for structure, conventions, and architecture. Read the + relevant section before changing code; update this file whenever you add or change a pattern, + data flow, folder, or significant feature. +- The template is intentionally lean: a modern `@Observable` SwiftUI app skeleton, a reusable + component/extension/utility toolkit, a Settings/Support screen pair, Home Screen quick actions, + and a full fastlane + GitHub Actions build/release pipeline. It has no third-party dependencies; + add whatever your app needs. + +## Working agreements + +Act as the senior engineer responsible for high-leverage, production-safe changes: + +1. **Clarify scope first** β€” map the approach and the files you will touch before writing code. +2. **Locate the exact insertion point** β€” no sweeping edits across unrelated files; justify every + file you touch. No new abstractions or refactors unless the task explicitly calls for them. +3. **Minimal, contained changes** β€” only what the task requires; no speculative "while we're here" + edits, no drive-by logging, comments, or TODOs. +4. **Double-check everything** β€” correctness, scope adherence, side effects, downstream impact. +5. **Deliver clearly** β€” summarize what changed and why, per file; flag assumptions and risks. + +## Toolchain & project settings + +| What | Source of truth | +|---|---| +| Xcode version | `XCODE_VERSION` env in `.github/workflows/build.yml` / `releasing.yml` | +| Ruby 3.3 + gems (fastlane) | `Gemfile` / `Gemfile.lock` (vendored in `vendor/bundle`) | +| SwiftFormat / SwiftLint / SwiftGen | `Mintfile` (run via [Mint](https://github.com/yonaskolb/Mint)) | +| Deployment target / Swift version | `IPHONEOS_DEPLOYMENT_TARGET` / `SWIFT_VERSION` in the pbxproj | +| App identifier, scheme, envs | `fastlane/.env`, `.env.stg`, `.env.prd` | + +- Swift 6 language mode; concurrency is adopted via `@MainActor` + `async/await` (see below). +- The `@Observable`-based architecture requires an iOS 17+ deployment target. +- SwiftUI app lifecycle only β€” no storyboards; UIKit is bridged where SwiftUI has gaps + (`UIApplicationDelegateAdaptor`, representables). +- Always run fastlane through bundler: `bundle exec fastlane `. + +## Project structure + +``` +Template/ +β”œβ”€β”€ TemplateApp.swift # @main App β€” SwiftUI entry point +β”œβ”€β”€ AppDelegate.swift # UIKit bridge: quick-action registration + SceneDelegate vending +β”œβ”€β”€ Assets/ # Asset catalogs + generated accessors (Assets.swift, Colors.swift) +β”œβ”€β”€ Components/ # Shared, reusable UI components (mirror Figma design components) +β”‚ └── SomeComponent/ # complex components get a folder + their own ViewModel +β”œβ”€β”€ Extensions/ # One file per extended type: String.swift, URL.swift, … +β”œβ”€β”€ Models/ # Data models, shared enums, error types (Errors.swift) +β”œβ”€β”€ Screens/ # One folder per screen: FooView.swift + FooViewModel.swift +β”‚ └── Foo/Components/ # screen-specific components +β”œβ”€β”€ Services/ # Business logic + external integrations (one file/folder each) +β”œβ”€β”€ Utilities/ # Globals.swift (MainConstants), Logger, Dependencies, ViewModifiers… +β”œβ”€β”€ Settings.bundle/ # Open-source credits (generated by scripts/credits.py) +β”œβ”€β”€ Localizable.xcstrings # String catalog (+ InfoPlist.xcstrings) β€” add with first localized string +└── Info.plist +TemplateTests/ # Swift Testing unit tests β€” folder layout mirrors the app target +TemplateUITests/ # XCTest UI tests +``` + +Several folders contain a `README.md` with detailed conventions β€” keep those current. + +- **Screens** are: a tab's page, a `NavigationStack` root or destination, or a presented sheet / + full-screen cover. The bundled `Screens/Settings` (Settings + Support) is the exemplar. +- Shared components live in `Components/`; screen-specific ones in `Screens//Components/`. +- **Extensions** are named after the type they extend (`Date.swift`, not `Date+Formatting.swift`); + conversion helpers use the `as` prefix (`var asString: String`). +- **Models vs Services vs Managers:** data shapes and enums β†’ `Models/`; integrations and business + logic with a clean API for ViewModels β†’ `Services/`; app-wide orchestration singletons β†’ + `Managers/` (add the folder when the first one appears). +- App extensions (widgets etc.) get a sibling top-level target folder. There is no shared + framework β€” share code via multi-target membership. + +## Architecture β€” MVVM with @Observable + +### ViewModels +- `@MainActor @Observable final class FooViewModel` β€” modern Observation only. Never use + `ObservableObject` / `@Published` / `@StateObject` / `@ObservedObject` for our own types; the + only exception is observing an external SDK's `ObservableObject`. +- Screen- or component-owned ViewModel: hold it in `@State`, inject dependencies through the init: + + ```swift + struct FooView: View { + @State private var viewModel: FooViewModel + + init(item: Item) { + _viewModel = State(wrappedValue: FooViewModel(item: item)) + } + } + ``` +- App-wide observable state: create once as `@State` in the `App`, inject with `.environment(...)`, + read with `@Environment(FooViewModel.self) private var viewModel`. +- The view's property is always named `viewModel`, whatever the type. When bindings are needed, + start `body` with `@Bindable var viewModel = viewModel`. +- `@Observable` is not reserved for ViewModels β€” models with UI-relevant mutable state, + coordinators, and singleton bridges use it too (e.g. `QuickActionsService`). +- Inside `@Observable` classes, prefix `@AppStorage` properties with `@ObservationIgnored`. + +### Views +- Decompose `body` into computed properties/functions grouped in `private extension FooView` + blocks with `// MARK: -` headers; use `@ViewBuilder` / `@ToolbarContentBuilder`. +- No file-header comments β€” files start at `import`. +- No SwiftUI previews β€” we don't keep `#Preview` / `PreviewProvider` blocks. +- Navigation: one `NavigationStack` per flow with `.navigationDestination(isPresented:)`, + `.sheet`, `.fullScreenCover`. Multi-step flows get an `@Observable` coordinator owning a + `NavigationPath`. + +### Services & dependency injection +- Services are `final class`es or caseless `enum`s exposing static facades or singletons + (`FooService.shared`). Long-lived instances conform to `Sendable` (or `@unchecked Sendable` with + documented internal safety). +- `Utilities/Dependencies.swift` is a lightweight static container for instance-based services; + ViewModels consume `Dependencies.fooService` instead of instantiating their own. +- Testability comes from initializer injection (inject a UserDefaults key/suite, a path, a + configuration) β€” not from mock classes. + +### Concurrency +- `@MainActor` on every ViewModel and UI-touching service; `async/await` + `Task {}` elsewhere; + no custom actors so far. Bridge callback APIs with `withCheckedThrowingContinuation`. + +### Errors, alerts, logging +- Error enums live in `Models/Errors.swift` and conform to `Error, LocalizedError` with + developer-facing `errorDescription`; service-specific errors may nest inside the service. +- User-facing alerts flow through a single `enum AlertType: Identifiable` + (`Utilities/DialogTypes.swift`) with associated-value closures, presented via the + `.alert(_: Binding)` view extension; ViewModels expose `var alert: AlertType?`. + User-facing copy is localized at the call site β€” never inside the `Error` type. +- Log via `Utilities/Logger.swift`, a thin wrapper around `os.Logger` with per-category static + accessors (`Logger.general.error("...")`), message format `[CATEGORY]: …`. No `print()`, no + third-party loggers. + +### Persistence +- UserDefaults + files only; no CoreData/SwiftData/Keychain unless a feature demands it. +- All defaults keys live in a typed enum in `Extensions/UserDefaults.swift`, grouped by MARK, with + namespaced raw values and type-safe accessors: + + ```swift + extension UserDefaults { + enum Key: String { + case hasCompletedFirstLaunch = "app.hasCompletedFirstLaunch" + } + } + ``` +- The template is deliberately app-group-free. When the app gains an extension (widget, share, …), + register an app group, add a `UserDefaults.sharedSuite`, split `Key` into app-only vs shared + keys, and use the app-group `FileManager.containerURL(...)` for larger blobs. + +### Constants +- `struct MainConstants` in `Utilities/Globals.swift` holds the App Store id/links, legal URLs, and + support email. Placeholders are marked `// <- CHANGE`. When you add a third-party SDK, its + client-side key belongs here too (client-side keys are committed on purpose). + +## Bundled app infrastructure + +- **Settings + Support screens** (`Screens/Settings/`): language row, mail support (prefilled with + version/build from `MainConstants.supportEmail`), legal links, share sheet, version footer. The + `SettingsView` is the app's root view out of the box. +- **Home Screen quick actions**: `QuickActionsService` (`@Observable` singleton bridge) + + `AppDelegate`/`SceneDelegate`. The bundled "Send Feedback" action opens the Support screen; cold + launches replay via the `WindowGroup` `.task`. This is the reference pattern for bridging the + UIKit scene-delegate callbacks SwiftUI doesn't expose. +- **App rating**: `AppStore.requestReview(in:)` at value moments is the recommended pattern (not + bundled as a screen). +- **Open-source licenses**: `scripts/credits.py` compiles `LICENSE.*` files of SPM checkouts into + `Settings.bundle/Credits.plist`. +- **Reusable toolkit** (`Components/`, `Extensions/`, `Utilities/`): loading indicators, mail/share + representables, a keyboard-height publisher, SwiftUI availability backports, view modifiers, + `ScreenState`, and localized-string shorthands. Some of this is intentionally unused scaffolding β€” + it is there for the app you build on top. + +## Localization + +- String Catalogs only: `Template/Localizable.xcstrings` (+ `InfoPlist.xcstrings`), one per target + (added with the first localized string). +- In code: `Text("…", comment: "…")`, `String(localized:comment:)`, `LocalizedStringResource` β€” + never `NSLocalizedString`. **Every string carries a real translator comment** (context, + placeholders, tone). Very common strings get shorthand statics + (`Text(.ok)`, `Text(.cancel)`) in `Extensions/LocalizedStringResource.swift`. + +## Testing + +- Unit tests use **Swift Testing** (`import Testing`, `@Suite`, `@Test("description")`, + `#expect`, `#expect(throws:)`) with `@testable import Template`. Struct suites with setup in + `init()`; add `.serialized` only when tests share mutable state. +- The test folder layout mirrors the app target (`TemplateTests/Extensions/…`, `Services/…`). +- Test services via initializer injection (custom UserDefaults suite/key, injected paths) β€” no + mock classes. +- UI tests are XCTest, in `TemplateUITests/`. +- Keep a fast CI test plan alongside the full one when the suite grows + (`run_tests` uses `-skipMacroValidation`). + +## Formatting & linting + +- SwiftFormat + SwiftLint run via Mint (versions pinned in `Mintfile`): locally through the + pre-commit hook or `bundle exec fastlane format` / `lint`; CI enforces both (SwiftLint + `--strict`, SwiftFormat lint-only). +- Key rules (`.swiftformat`, `.swiftlint.yml`): max line width 160; `organizeDeclarations` with + `// MARK: - ` markers; `guard … else {` on the same line; wrap arguments + `after-first`; `isEmpty` over `count == 0`; file length warn 500 / error 750; identifiers β‰₯ 3 + chars (`id`, `ok`, `no`, `to` exempt); nesting limits; `trailing_comma` disabled. +- Generated files (`Assets.swift`, `Colors.swift`, `*.generated.swift`) are excluded from both + tools. +- Inline `// swiftlint:disable:this ` is acceptable sparingly where a rule misfires. + +## Git conventions (enforced by hooks) + +`bundle exec fastlane setup` symlinks `hooks/` into `.git/hooks`. + +- Branch names: `main`, `release/x.y.z`, or `/` (customize the regex in + `hooks/pre-commit` if your team uses a ticket convention). +- Commit messages: a subject of at least 15 characters, optional `[Tag]` prefixes; merge commits + are exempt (see `hooks/commit-msg`). +- pre-commit: sorts the pbxproj (`scripts/sort-xcode-project-file.pl`), then per staged Swift file + runs SwiftFormat, SwiftLint autocorrect, and strict SwiftLint β€” any reformat or violation fails + the commit so you can review, re-stage, and commit again. +- PRs target `main`. + +## fastlane & CI + +Lanes (always `bundle exec fastlane `, environment via `--env stg|prd`): + +| Lane | Purpose | +|---|---| +| `lint` / `format` | SwiftLint (strict) / SwiftFormat via Mint | +| `assets` | Regenerate `Assets.swift` from `Assets.xcassets` via SwiftGen (images only β€” icons belong in `Symbols.xcassets` as custom SF symbols) | +| `test` | `run_tests` with the project scheme | +| `updateVersion` | Calendar version `..`, auto-bumping patch within a week against the live App Store version | +| `build` | match signing, build number from increment.build, STG app-icon swap, gym export | +| `deploy` | Upload the built ipa to TestFlight (stg: internal, silent; prd: external groups) | +| `upload_metadata` | Push App Store metadata / create a version | +| `rename` / `setup` / `codeSigning` / `registerDevice` / `simulator` | Project bootstrap & signing utilities | + +Versioning: marketing version = `year.week.patch` (set to `..0` at rename); build +number = global incrementing counter. + +Workflows (`.github/workflows/`): +- `linting.yml` β€” SwiftLint + SwiftFormat on every PR and main (tool versions read from `Mintfile`). +- `build.yml` β€” PRs + pushes to main: staging build β†’ TestFlight (skips drafts). +- `releasing.yml` β€” manual dispatch (stg/prd): `updateVersion` β†’ `build` β†’ `deploy` β†’ git tag + `-` β†’ `upload_metadata`. +- `releaseMonitor.yml` β€” hourly App Store poll, creates a GitHub Release on `READY_FOR_SALE` + (enable the schedule after the first release; set the `APP_STORE_APP_ID` repo var). + +Build/release workflows are skipped while the repository name contains "template". + +## Creating a new app from this template + +1. Follow `README.md`: clone, `bundle install`, `bundle exec fastlane rename new_name:`, + review the rename diff, `bundle exec fastlane setup`. +2. Fill every `// <- CHANGE` placeholder in `Utilities/Globals.swift` (App Store id, links, legal + URLs, support email) and review `fastlane/Deliverfile` / `Matchfile` values (App Store Connect + team id, certificates repo). +3. Info.plist & entitlements: add a URL scheme when needed, plus Associated Domains for universal + links. When adding an app extension, also register an app group and move shared state into a + shared UserDefaults suite. +4. GitHub repo: configure Actions secrets/vars used by the workflows (App Store Connect API key + trio, `MATCH_*`, the bot SSH key/token, Slack channel ids) and branch protection on `main`. +5. After the first App Store release: enable the `releaseMonitor.yml` schedule. diff --git a/Gemfile b/Gemfile index 43300d1..f42fb2f 100644 --- a/Gemfile +++ b/Gemfile @@ -4,3 +4,6 @@ git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } gem 'fastlane' gem 'rufo' + +plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile') +eval_gemfile(plugins_path) if File.exist?(plugins_path) diff --git a/Gemfile.lock b/Gemfile.lock index e5830a9..39f38f8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,47 +1,50 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.7) - base64 - nkf - rexml - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + CFPropertyList (3.0.8) + abbrev (0.1.2) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) artifactory (3.0.17) atomos (0.1.3) - aws-eventstream (1.3.2) - aws-partitions (1.1084.0) - aws-sdk-core (3.222.1) + aws-eventstream (1.4.0) + aws-partitions (1.1265.0) + aws-sdk-core (3.252.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) base64 + bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.99.0) - aws-sdk-core (~> 3, >= 3.216.0) + aws-sdk-kms (1.129.0) + aws-sdk-core (~> 3, >= 3.248.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.183.0) - aws-sdk-core (~> 3, >= 3.216.0) + aws-sdk-s3 (1.226.0) + aws-sdk-core (~> 3, >= 3.248.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sigv4 (1.11.0) + aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) - base64 (0.2.0) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.1.2) claide (1.1.0) colored (1.2) colored2 (3.1.2) commander (4.6.0) highline (~> 2.0.0) + csv (3.3.5) declarative (0.0.20) digest-crc (0.7.0) rake (>= 12.0.0, < 14.0.0) domain_name (0.6.20240107) dotenv (2.8.1) emoji_regex (3.2.3) - excon (0.112.0) - faraday (1.10.4) + excon (1.5.0) + logger + faraday (1.10.6) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) @@ -53,53 +56,62 @@ GEM faraday-rack (~> 1.0) faraday-retry (~> 1.0) ruby2_keywords (>= 0.0.4) - faraday-cookie_jar (0.0.7) + faraday-cookie_jar (0.0.8) faraday (>= 0.8.0) - http-cookie (~> 1.0.0) + http-cookie (>= 1.0.0) faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.0) + faraday-em_synchrony (1.0.1) faraday-excon (1.1.0) faraday-httpclient (1.0.1) - faraday-multipart (1.1.0) + faraday-multipart (1.2.0) multipart-post (~> 2.0) faraday-net_http (1.0.2) faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) faraday-rack (1.0.0) - faraday-retry (1.0.3) + faraday-retry (1.0.4) faraday_middleware (1.2.1) faraday (~> 1.0) - fastimage (2.4.0) - fastlane (2.227.1) - CFPropertyList (>= 2.3, < 4.0.0) - addressable (>= 2.8, < 3.0.0) + fastimage (2.4.1) + fastlane (2.237.0) + CFPropertyList (>= 2.3, < 5.0.0) + abbrev (~> 0.1) + addressable (>= 2.9.0, < 3.0.0) artifactory (~> 3.0) - aws-sdk-s3 (~> 1.0) + aws-sdk-s3 (~> 1.197) babosa (>= 1.0.3, < 2.0.0) - bundler (>= 1.12.0, < 3.0.0) + base64 (~> 0.2) + benchmark (>= 0.1.0) + bundler (>= 2.4.0, < 5.0.0) colored (~> 1.2) commander (~> 4.6) + csv (~> 3.3) dotenv (>= 2.1.1, < 3.0.0) emoji_regex (>= 0.1, < 4.0) - excon (>= 0.71.0, < 1.0.0) + excon (>= 0.71.0, < 2.0.0) faraday (~> 1.0) faraday-cookie_jar (~> 0.0.6) faraday_middleware (~> 1.0) fastimage (>= 2.1.0, < 3.0.0) - fastlane-sirp (>= 1.0.0) + fastlane-sirp (>= 1.1.0) gh_inspector (>= 1.1.2, < 2.0.0) google-apis-androidpublisher_v3 (~> 0.3) google-apis-playcustomapp_v1 (~> 0.1) - google-cloud-env (>= 1.6.0, < 2.0.0) + google-cloud-env (>= 1.6.0, < 2.3.0) google-cloud-storage (~> 1.31) highline (~> 2.0) http-cookie (~> 1.0.5) json (< 3.0.0) - jwt (>= 2.1.0, < 3) + jwt (>= 2.10.3, < 4) + logger (>= 1.6, < 2.0) mini_magick (>= 4.9.4, < 5.0.0) + multi_json (~> 1.12) multipart-post (>= 2.0.0, < 3.0.0) + mutex_m (~> 0.3) naturally (~> 2.2) + nkf (~> 0.2) optparse (>= 0.1.1, < 1.0.0) + ostruct (>= 0.1.0) plist (>= 3.1.0, < 4.0.0) rubyzip (>= 2.0.0, < 3.0.0) security (= 0.1.5) @@ -112,44 +124,49 @@ GEM xcodeproj (>= 1.13.0, < 2.0.0) xcpretty (~> 0.4.1) xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) - fastlane-sirp (1.0.0) - sysrandom (~> 1.0) + fastlane-plugin-versioning (0.7.1) + fastlane-sirp (1.1.0) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.54.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-core (0.11.3) + google-apis-androidpublisher_v3 (0.104.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-core (0.18.0) addressable (~> 2.5, >= 2.5.1) - googleauth (>= 0.16.2, < 2.a) - httpclient (>= 2.8.1, < 3.a) + googleauth (~> 1.9) + httpclient (>= 2.8.3, < 3.a) mini_mime (~> 1.0) + mutex_m representable (~> 3.0) retriable (>= 2.0, < 4.a) - rexml - google-apis-iamcredentials_v1 (0.17.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-playcustomapp_v1 (0.13.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-storage_v1 (0.31.0) - google-apis-core (>= 0.11.0, < 2.a) - google-cloud-core (1.8.0) + google-apis-iamcredentials_v1 (0.28.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-playcustomapp_v1 (0.18.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-storage_v1 (0.64.0) + google-apis-core (>= 0.15.0, < 2.a) + google-cloud-core (1.9.0) google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) - google-cloud-env (1.6.0) - faraday (>= 0.17.3, < 3.0) - google-cloud-errors (1.5.0) - google-cloud-storage (1.47.0) + google-cloud-env (2.2.2) + base64 (~> 0.2) + faraday (>= 1.0, < 3.a) + google-cloud-errors (1.6.0) + google-cloud-storage (1.62.0) addressable (~> 2.8) digest-crc (~> 0.4) - google-apis-iamcredentials_v1 (~> 0.1) - google-apis-storage_v1 (~> 0.31.0) + google-apis-core (>= 0.18, < 2) + google-apis-iamcredentials_v1 (~> 0.18) + google-apis-storage_v1 (>= 0.42) google-cloud-core (~> 1.6) - googleauth (>= 0.16.2, < 2.a) + googleauth (~> 1.9) mini_mime (~> 1.0) - googleauth (1.8.1) - faraday (>= 0.17.3, < 3.a) - jwt (>= 1.4, < 3.0) - multi_json (~> 1.11) + google-logging-utils (0.2.0) + googleauth (1.17.1) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.2) + google-logging-utils (~> 0.1) + jwt (>= 1.4, < 4.0) os (>= 0.9, < 2.0) + pstore (~> 0.1) signet (>= 0.16, < 2.a) highline (2.0.3) http-cookie (1.0.8) @@ -157,43 +174,43 @@ GEM httpclient (2.9.0) mutex_m jmespath (1.6.2) - json (2.10.2) - jwt (2.10.1) + json (2.20.0) + jwt (3.2.0) base64 logger (1.7.0) mini_magick (4.13.2) mini_mime (1.1.5) - multi_json (1.15.0) + multi_json (1.21.1) multipart-post (2.4.1) mutex_m (0.3.0) nanaimo (0.4.0) - naturally (2.2.1) - nkf (0.2.0) - optparse (0.6.0) + naturally (2.3.0) + nkf (0.3.0) + optparse (0.8.1) os (1.1.4) + ostruct (0.6.3) plist (3.7.2) - public_suffix (6.0.1) - rake (13.2.1) + pstore (0.2.1) + public_suffix (7.0.5) + rake (13.4.2) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) - retriable (3.1.2) - rexml (3.4.1) + retriable (3.8.0) + rexml (3.4.4) rouge (3.28.0) ruby2_keywords (0.0.5) rubyzip (2.4.1) rufo (0.16.1) security (0.1.5) - signet (0.19.0) + signet (0.22.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) - jwt (>= 1.5, < 3.0) - multi_json (~> 1.10) + jwt (>= 1.5, < 4.0) simctl (1.6.10) CFPropertyList naturally - sysrandom (1.0.5) terminal-notifier (2.0.0) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) @@ -220,9 +237,11 @@ GEM PLATFORMS arm64-darwin-22 arm64-darwin-23 + arm64-darwin-25 DEPENDENCIES fastlane + fastlane-plugin-versioning rufo BUNDLED WITH diff --git a/Mintfile b/Mintfile index fdbd4c0..962ffcf 100644 --- a/Mintfile +++ b/Mintfile @@ -1,5 +1,5 @@ # Source https://github.com/yonaskolb/Mint -nicklockwood/SwiftFormat@0.55.5 -realm/SwiftLint@0.59.0 +nicklockwood/SwiftFormat@0.58.1 +realm/SwiftLint@0.61.0 SwiftGen/SwiftGen@6.6.3 diff --git a/README.md b/README.md index 4b490f7..edafc64 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,10 @@ My favorite Xcode project structure, plus SwiftLint and SwiftFormat pre-configur ruby --version ``` - Ideally it show you something like this (Required: `>= 2.7.0`): + Ideally it show you something like this (Required: `>= 3.3.0`): ```sh - ruby 3.1.1p18 (2022-02-18 revision 53f5fc4236) [arm64-darwin22] + ruby 3.3.0 (2023-12-25 revision 5124f9ac75) [arm64-darwin23] ``` > Check the [Troubleshooting Guide](https://github.com/hoppsen/xcode-template/wiki/Troubleshooting-Guide#how-to-fix-your-ruby-version) to fix your Ruby version. @@ -46,22 +46,24 @@ My favorite Xcode project structure, plus SwiftLint and SwiftFormat pre-configur bundle install ``` -7. Run our guided setup script. +7. Rename the whole project to a name of your choice. ```sh - bundle exec fastlane setup + bundle exec fastlane rename new_name:Hoppsen ``` -8. (Optional) Rename the whole project to a name of your choice. +8. Validate all name changes within Git + +9. Run our guided setup script. ```sh - bundle exec fastlane rename new_name:Hoppsen + bundle exec fastlane setup ``` -9. Open `Template.xcodeproj` (or new name if renamed before) +10. Open `Template.xcodeproj` -10. Start coding :rocket: +11. Start coding :rocket: --- -Project created with template from [https://github.com/hoppsen/xcode-template](https://github.com/hoppsen/xcode-template). \ No newline at end of file +Project created with template from [https://github.com/hoppsen/xcode-template](https://github.com/hoppsen/xcode-template). diff --git a/Template.xcodeproj/project.pbxproj b/Template.xcodeproj/project.pbxproj index a371f88..1d116a6 100644 --- a/Template.xcodeproj/project.pbxproj +++ b/Template.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 05DD11168168CB47EF3F4DAE /* LoadingDotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 141A5F668771EB3404723969 /* LoadingDotView.swift */; }; + 15038DD00BDCEF3CA73FAEA7 /* QuickActionsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 328BC799A3B2A009DCA33948 /* QuickActionsService.swift */; }; 1F65A1E82A715C3700FBA141 /* TemplateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F65A1E72A715C3700FBA141 /* TemplateTests.swift */; }; 1F65A1F52A715C5900FBA141 /* TemplateUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F65A1F42A715C5900FBA141 /* TemplateUITests.swift */; }; 1F65A1F72A715C5900FBA141 /* TemplateUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F65A1F62A715C5900FBA141 /* TemplateUITestsLaunchTests.swift */; }; @@ -20,12 +22,28 @@ 1FAC1FD92A6B05B10083E64C /* Colors.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1FAC1FD82A6B05B10083E64C /* Colors.xcassets */; }; 1FAC1FDE2A6B063C0083E64C /* SFSafariView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FAC1FDD2A6B063C0083E64C /* SFSafariView.swift */; }; 1FAC1FE02A6B06520083E64C /* ShareSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FAC1FDF2A6B06520083E64C /* ShareSheetView.swift */; }; - 1FAC1FE32A6B08530083E64C /* AboutViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FAC1FDB2A6B05F80083E64C /* AboutViewModel.swift */; }; - 1FAC1FE42A6B08530083E64C /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FAC1FDC2A6B05F80083E64C /* AboutView.swift */; }; 1FAC1FE52A6B08580083E64C /* Globals.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FAC1FE22A6B077B0083E64C /* Globals.swift */; }; 1FAC1FE72A6B08860083E64C /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FAC1FE62A6B087E0083E64C /* UIApplication.swift */; }; 1FAC1FE92A6B08A30083E64C /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FAC1FE82A6B08A30083E64C /* URL.swift */; }; 1FAC1FF02A6B0E280083E64C /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 1FAC1FEF2A6B0E280083E64C /* Settings.bundle */; }; + 46B19346B282654F791BAC4A /* ViewModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF39C050B476630BFB0F0BBF /* ViewModifiers.swift */; }; + 5B65FC4E1994D4438CA68705 /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3539E37B3E91B33AB1B1D419 /* Backport.swift */; }; + 763B4E0BE449268B190EB0D9 /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24685966D77985AFF57E7EC4 /* UserDefaults.swift */; }; + 7642F934F6DD0EFC3D1E36BE /* SupportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB3CDDD3C0E0377926925685 /* SupportView.swift */; }; + 7D72FC38E80577E263A69244 /* StarsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3BD7E41A5794170CFBB49F7 /* StarsView.swift */; }; + 7FF3AD59A78D48857230C061 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8833D8C5C99372FA898D236 /* SettingsView.swift */; }; + 8488433FA98540B05696632E /* DialogTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 855A2B710C5B2317A38BA051 /* DialogTypes.swift */; }; + 859DB0B286763C1E3EEF7079 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CAE00A5289AB3181D6DB84E /* Logger.swift */; }; + 8925E657A31ABE3F8DDC9397 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3351DA580E36E959F808239C /* AppDelegate.swift */; }; + 8B43A0EDEF4080D0710EEF83 /* KeyboardListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AD8C0CAB13D5A1A4ACC58F2 /* KeyboardListener.swift */; }; + 948E43F75B97933DAA11DF29 /* MailComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B611CB55C1B372F8A8D03C67 /* MailComposeView.swift */; }; + 96CE77D681249689F43B0C2C /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B22E407C22AAAF8E0FBB5EA /* String.swift */; }; + A50E2987FBF2BA3CEA4C11B2 /* ScreenState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D592E0C874FC859A19445995 /* ScreenState.swift */; }; + ABF9E6FB4F51188129504065 /* LocalizedStringResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = F875E8FF9252F785BBCC693F /* LocalizedStringResource.swift */; }; + B9A1A9B97361DEFA8743FF64 /* Dictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1377D106D1F82A4364A8CE32 /* Dictionary.swift */; }; + C9C4EDA6E4A077A876E66D8A /* Dependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA301882E12447E0E5D65194 /* Dependencies.swift */; }; + CF766D03F8B0DA8F49575AE0 /* UIDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB666EC5D1EF70B63667EDA8 /* UIDevice.swift */; }; + EADAC2A45B2A9ACB2E4FB822 /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F58C8CE3A3817D5C6B78BDB0 /* Bundle.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -46,6 +64,9 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 0AD8C0CAB13D5A1A4ACC58F2 /* KeyboardListener.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = KeyboardListener.swift; sourceTree = ""; }; + 1377D106D1F82A4364A8CE32 /* Dictionary.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Dictionary.swift; sourceTree = ""; }; + 141A5F668771EB3404723969 /* LoadingDotView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LoadingDotView.swift; sourceTree = ""; }; 1F1104962DA783B00095B283 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 1F65A1E52A715C3700FBA141 /* TemplateTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TemplateTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 1F65A1E72A715C3700FBA141 /* TemplateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateTests.swift; sourceTree = ""; }; @@ -62,8 +83,6 @@ 1FAC1FD32A6B042C0083E64C /* TitleSubtitleRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitleSubtitleRow.swift; sourceTree = ""; }; 1FAC1FD62A6B04AE0083E64C /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = ""; }; 1FAC1FD82A6B05B10083E64C /* Colors.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Colors.xcassets; sourceTree = ""; }; - 1FAC1FDB2A6B05F80083E64C /* AboutViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutViewModel.swift; sourceTree = ""; }; - 1FAC1FDC2A6B05F80083E64C /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; 1FAC1FDD2A6B063C0083E64C /* SFSafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFSafariView.swift; sourceTree = ""; }; 1FAC1FDF2A6B06520083E64C /* ShareSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheetView.swift; sourceTree = ""; }; 1FAC1FE22A6B077B0083E64C /* Globals.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Globals.swift; sourceTree = ""; }; @@ -75,6 +94,25 @@ 1FAC1FED2A6B0C410083E64C /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 1FAC1FEE2A6B0C910083E64C /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 1FAC1FEF2A6B0E280083E64C /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; + 24685966D77985AFF57E7EC4 /* UserDefaults.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserDefaults.swift; sourceTree = ""; }; + 2CAE00A5289AB3181D6DB84E /* Logger.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; + 328BC799A3B2A009DCA33948 /* QuickActionsService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = QuickActionsService.swift; sourceTree = ""; }; + 3351DA580E36E959F808239C /* AppDelegate.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 3539E37B3E91B33AB1B1D419 /* Backport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = ""; }; + 6B22E407C22AAAF8E0FBB5EA /* String.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; + 855A2B710C5B2317A38BA051 /* DialogTypes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DialogTypes.swift; sourceTree = ""; }; + A1358D8D6D94AD58999AA986 /* README.md */ = {isa = PBXFileReference; includeInIndex = 1; path = README.md; sourceTree = ""; }; + AA301882E12447E0E5D65194 /* Dependencies.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Dependencies.swift; sourceTree = ""; }; + AB666EC5D1EF70B63667EDA8 /* UIDevice.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UIDevice.swift; sourceTree = ""; }; + B3BD7E41A5794170CFBB49F7 /* StarsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StarsView.swift; sourceTree = ""; }; + B611CB55C1B372F8A8D03C67 /* MailComposeView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MailComposeView.swift; sourceTree = ""; }; + CF39C050B476630BFB0F0BBF /* ViewModifiers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ViewModifiers.swift; sourceTree = ""; }; + D592E0C874FC859A19445995 /* ScreenState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ScreenState.swift; sourceTree = ""; }; + D8833D8C5C99372FA898D236 /* SettingsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + EB3CDDD3C0E0377926925685 /* SupportView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SupportView.swift; sourceTree = ""; }; + F58C8CE3A3817D5C6B78BDB0 /* Bundle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Bundle.swift; sourceTree = ""; }; + F86336A4D608BFD39981D3D5 /* README.md */ = {isa = PBXFileReference; includeInIndex = 1; path = README.md; sourceTree = ""; }; + F875E8FF9252F785BBCC693F /* LocalizedStringResource.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalizedStringResource.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -145,13 +183,16 @@ 1FAC1FC02A6AF4980083E64C /* Assets */, 1FAC1FCE2A6AF5250083E64C /* Components */, 1FAC1FCF2A6AF52E0083E64C /* Extensions */, + E1634A7EAD7CF6EDA0AABFDE /* Models */, 1FAC1FB82A6AF4840083E64C /* Preview Content */, 1FAC1FD02A6AF5370083E64C /* Screens */, + 8E8C8EC79630713982CEC1C1 /* Services */, 1FAC1FE12A6B07700083E64C /* Utilities */, + 3351DA580E36E959F808239C /* AppDelegate.swift */, + 1F1104962DA783B00095B283 /* Info.plist */, 1FAC1FEF2A6B0E280083E64C /* Settings.bundle */, 1FAC1FB72A6AF4840083E64C /* Template.entitlements */, 1FAC1FB12A6AF4830083E64C /* TemplateApp.swift */, - 1F1104962DA783B00095B283 /* Info.plist */, ); path = Template; sourceTree = ""; @@ -180,10 +221,13 @@ isa = PBXGroup; children = ( 1FAC1FD22A6B040C0083E64C /* ChevronView.swift */, + 141A5F668771EB3404723969 /* LoadingDotView.swift */, + B611CB55C1B372F8A8D03C67 /* MailComposeView.swift */, 1FAC1FEB2A6B0BDC0083E64C /* README.md */, - 1FAC1FD32A6B042C0083E64C /* TitleSubtitleRow.swift */, 1FAC1FDD2A6B063C0083E64C /* SFSafariView.swift */, 1FAC1FDF2A6B06520083E64C /* ShareSheetView.swift */, + B3BD7E41A5794170CFBB49F7 /* StarsView.swift */, + 1FAC1FD32A6B042C0083E64C /* TitleSubtitleRow.swift */, ); path = Components; sourceTree = ""; @@ -191,9 +235,15 @@ 1FAC1FCF2A6AF52E0083E64C /* Extensions */ = { isa = PBXGroup; children = ( + F58C8CE3A3817D5C6B78BDB0 /* Bundle.swift */, + 1377D106D1F82A4364A8CE32 /* Dictionary.swift */, + F875E8FF9252F785BBCC693F /* LocalizedStringResource.swift */, 1FAC1FEA2A6B0BC20083E64C /* README.md */, + 6B22E407C22AAAF8E0FBB5EA /* String.swift */, 1FAC1FE62A6B087E0083E64C /* UIApplication.swift */, + AB666EC5D1EF70B63667EDA8 /* UIDevice.swift */, 1FAC1FE82A6B08A30083E64C /* URL.swift */, + 24685966D77985AFF57E7EC4 /* UserDefaults.swift */, ); path = Extensions; sourceTree = ""; @@ -201,28 +251,55 @@ 1FAC1FD02A6AF5370083E64C /* Screens */ = { isa = PBXGroup; children = ( - 1FAC1FDA2A6B05DF0083E64C /* About */, + D0A32FA9A201127F3C472AD6 /* Settings */, 1FAC1FED2A6B0C410083E64C /* README.md */, ); path = Screens; sourceTree = ""; }; - 1FAC1FDA2A6B05DF0083E64C /* About */ = { + 1FAC1FE12A6B07700083E64C /* Utilities */ = { isa = PBXGroup; children = ( - 1FAC1FDC2A6B05F80083E64C /* AboutView.swift */, - 1FAC1FDB2A6B05F80083E64C /* AboutViewModel.swift */, + 3539E37B3E91B33AB1B1D419 /* Backport.swift */, + AA301882E12447E0E5D65194 /* Dependencies.swift */, + 855A2B710C5B2317A38BA051 /* DialogTypes.swift */, + 1FAC1FE22A6B077B0083E64C /* Globals.swift */, + 0AD8C0CAB13D5A1A4ACC58F2 /* KeyboardListener.swift */, + 2CAE00A5289AB3181D6DB84E /* Logger.swift */, + 1FAC1FEE2A6B0C910083E64C /* README.md */, + CF39C050B476630BFB0F0BBF /* ViewModifiers.swift */, ); - path = About; + path = Utilities; sourceTree = ""; }; - 1FAC1FE12A6B07700083E64C /* Utilities */ = { + 8E8C8EC79630713982CEC1C1 /* Services */ = { isa = PBXGroup; children = ( - 1FAC1FE22A6B077B0083E64C /* Globals.swift */, - 1FAC1FEE2A6B0C910083E64C /* README.md */, + 328BC799A3B2A009DCA33948 /* QuickActionsService.swift */, + A1358D8D6D94AD58999AA986 /* README.md */, ); - path = Utilities; + name = Services; + path = Services; + sourceTree = ""; + }; + D0A32FA9A201127F3C472AD6 /* Settings */ = { + isa = PBXGroup; + children = ( + D8833D8C5C99372FA898D236 /* SettingsView.swift */, + EB3CDDD3C0E0377926925685 /* SupportView.swift */, + ); + name = Settings; + path = Settings; + sourceTree = ""; + }; + E1634A7EAD7CF6EDA0AABFDE /* Models */ = { + isa = PBXGroup; + children = ( + F86336A4D608BFD39981D3D5 /* README.md */, + D592E0C874FC859A19445995 /* ScreenState.swift */, + ); + name = Models; + path = Models; sourceTree = ""; }; /* End PBXGroup section */ @@ -317,8 +394,6 @@ Base, ); mainGroup = 1FAC1FA52A6AF4830083E64C; - packageReferences = ( - ); productRefGroup = 1FAC1FAF2A6AF4830083E64C /* Products */; projectDirPath = ""; projectRoot = ""; @@ -463,18 +538,36 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 1FAC1FE42A6B08530083E64C /* AboutView.swift in Sources */, - 1FAC1FE32A6B08530083E64C /* AboutViewModel.swift in Sources */, + 8925E657A31ABE3F8DDC9397 /* AppDelegate.swift in Sources */, 1F9E0E932A7183DC006CD009 /* Assets.swift in Sources */, + 5B65FC4E1994D4438CA68705 /* Backport.swift in Sources */, + EADAC2A45B2A9ACB2E4FB822 /* Bundle.swift in Sources */, 1FAC1FD52A6B04440083E64C /* ChevronView.swift in Sources */, 1FAC1FD72A6B04AE0083E64C /* Colors.swift in Sources */, + C9C4EDA6E4A077A876E66D8A /* Dependencies.swift in Sources */, + 8488433FA98540B05696632E /* DialogTypes.swift in Sources */, + B9A1A9B97361DEFA8743FF64 /* Dictionary.swift in Sources */, 1FAC1FE52A6B08580083E64C /* Globals.swift in Sources */, - 1FAC1FD42A6B042C0083E64C /* TitleSubtitleRow.swift in Sources */, + 8B43A0EDEF4080D0710EEF83 /* KeyboardListener.swift in Sources */, + 05DD11168168CB47EF3F4DAE /* LoadingDotView.swift in Sources */, + ABF9E6FB4F51188129504065 /* LocalizedStringResource.swift in Sources */, + 859DB0B286763C1E3EEF7079 /* Logger.swift in Sources */, + 948E43F75B97933DAA11DF29 /* MailComposeView.swift in Sources */, + 15038DD00BDCEF3CA73FAEA7 /* QuickActionsService.swift in Sources */, + A50E2987FBF2BA3CEA4C11B2 /* ScreenState.swift in Sources */, + 7FF3AD59A78D48857230C061 /* SettingsView.swift in Sources */, 1FAC1FDE2A6B063C0083E64C /* SFSafariView.swift in Sources */, 1FAC1FE02A6B06520083E64C /* ShareSheetView.swift in Sources */, + 7D72FC38E80577E263A69244 /* StarsView.swift in Sources */, + 96CE77D681249689F43B0C2C /* String.swift in Sources */, + 7642F934F6DD0EFC3D1E36BE /* SupportView.swift in Sources */, 1FAC1FB22A6AF4830083E64C /* TemplateApp.swift in Sources */, + 1FAC1FD42A6B042C0083E64C /* TitleSubtitleRow.swift in Sources */, 1FAC1FE72A6B08860083E64C /* UIApplication.swift in Sources */, + CF766D03F8B0DA8F49575AE0 /* UIDevice.swift in Sources */, 1FAC1FE92A6B08A30083E64C /* URL.swift in Sources */, + 763B4E0BE449268B190EB0D9 /* UserDefaults.swift in Sources */, + 46B19346B282654F791BAC4A /* ViewModifiers.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -605,7 +698,7 @@ COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = DY3TU95J5E; + DEVELOPMENT_TEAM = ""; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -666,7 +759,7 @@ COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = DY3TU95J5E; + DEVELOPMENT_TEAM = ""; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; diff --git a/Template.xcodeproj/xcshareddata/xcschemes/Template Staging.xcscheme b/Template.xcodeproj/xcshareddata/xcschemes/Template Staging.xcscheme new file mode 100644 index 0000000..66f8fe6 --- /dev/null +++ b/Template.xcodeproj/xcshareddata/xcschemes/Template Staging.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Template/AppDelegate.swift b/Template/AppDelegate.swift new file mode 100644 index 0000000..cd95104 --- /dev/null +++ b/Template/AppDelegate.swift @@ -0,0 +1,46 @@ +import UIKit + +/// Minimal UIKit app delegate bridged into the SwiftUI life cycle via +/// `@UIApplicationDelegateAdaptor`. Its sole responsibilities are registering the +/// Home Screen Quick Actions and vending a `SceneDelegate` so we can receive the +/// scene-based shortcut callbacks that SwiftUI does not expose natively. +final class AppDelegate: NSObject, UIApplicationDelegate { + func application(_: UIApplication, + didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + QuickActionsService.shared.registerShortcutItems() + return true + } + + func application(_: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options _: UIScene.ConnectionOptions) -> UISceneConfiguration { + let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) + if connectingSceneSession.role == .windowApplication { + configuration.delegateClass = SceneDelegate.self + } + return configuration + } +} + +/// Scene delegate that captures Home Screen Quick Actions and forwards them to +/// `QuickActionsService` for the SwiftUI layer to consume. +/// +/// SwiftUI continues to own the window and scene modifiers (`onOpenURL`, `scenePhase`, +/// etc.); this delegate only adds the quick-action callbacks that SwiftUI lacks. +final class SceneDelegate: NSObject, UIWindowSceneDelegate { + /// App launched from a terminated state via a quick action. + func scene(_: UIScene, + willConnectTo _: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions) { + if let shortcutItem = connectionOptions.shortcutItem { + QuickActionsService.shared.handle(shortcutItem) + } + } + + /// Quick action triggered while the app is already running (warm launch). + func windowScene(_: UIWindowScene, + performActionFor shortcutItem: UIApplicationShortcutItem, + completionHandler: @escaping (Bool) -> Void) { + completionHandler(QuickActionsService.shared.handle(shortcutItem)) + } +} diff --git a/Template/Assets/Colors.swift b/Template/Assets/Colors.swift index cf596fb..5f3590f 100644 --- a/Template/Assets/Colors.swift +++ b/Template/Assets/Colors.swift @@ -1,10 +1,3 @@ -// -// Colors.swift -// Template -// -// Created by Marcel Hoppe on 21.07.23. -// - import SwiftUI #if os(macOS) import AppKit diff --git a/Template/Components/ChevronView.swift b/Template/Components/ChevronView.swift index 289a443..00ace9e 100644 --- a/Template/Components/ChevronView.swift +++ b/Template/Components/ChevronView.swift @@ -1,10 +1,3 @@ -// -// ChevronView.swift -// Template -// -// Created by Marcel Hoppe on 21.07.23. -// - import SwiftUI struct ChevronView: View { diff --git a/Template/Components/LoadingDotView.swift b/Template/Components/LoadingDotView.swift new file mode 100644 index 0000000..d85b854 --- /dev/null +++ b/Template/Components/LoadingDotView.swift @@ -0,0 +1,40 @@ +import SwiftUI + +private struct LoadingDotView: View { + let delay: Double + @State private var scale: CGFloat = 0.35 + + var body: some View { + Circle() + .scaleEffect(scale) + .animation(Animation.easeInOut(duration: 0.6).repeatForever().delay(delay), value: scale) + .onAppear { + withAnimation { + scale = 0.7 + } + } + } +} + +// MARK: - ViewModifier + +struct LoadingThreeDotsModifier: ViewModifier { + let screenState: ScreenState? + + func body(content: Content) -> some View { + // Added via ZStack to keep the height based of the content + ZStack { + content + .isHidden(screenState?.isLoading ?? false) + + if let screenState = screenState, screenState.isLoading { + HStack { + LoadingDotView(delay: 0) + LoadingDotView(delay: 0.2) + LoadingDotView(delay: 0.4) + } + .frame(maxWidth: 50, maxHeight: 17) + } + } + } +} diff --git a/Template/Components/MailComposeView.swift b/Template/Components/MailComposeView.swift new file mode 100644 index 0000000..b1e1d21 --- /dev/null +++ b/Template/Components/MailComposeView.swift @@ -0,0 +1,31 @@ +import MessageUI +import SwiftUI + +struct MailComposeView: UIViewControllerRepresentable { + let recipients: [String] + let subject: String + let messageBody: String + + class Coordinator: NSObject, MFMailComposeViewControllerDelegate { + func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith _: MFMailComposeResult, error _: Error?) { + DispatchQueue.main.async { + controller.dismiss(animated: true, completion: nil) + } + } + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeUIViewController(context: Context) -> MFMailComposeViewController { + let mailComposeVC = MFMailComposeViewController() + mailComposeVC.mailComposeDelegate = context.coordinator + mailComposeVC.setToRecipients(recipients) + mailComposeVC.setSubject(subject) + mailComposeVC.setMessageBody(messageBody, isHTML: false) + return mailComposeVC + } + + func updateUIViewController(_: MFMailComposeViewController, context _: Context) {} +} diff --git a/Template/Components/SFSafariView.swift b/Template/Components/SFSafariView.swift index a28162a..40a38eb 100644 --- a/Template/Components/SFSafariView.swift +++ b/Template/Components/SFSafariView.swift @@ -1,10 +1,3 @@ -// -// SFSafariView.swift -// Template -// -// Created by Marcel Hoppe on 19.01.22. -// - import SafariServices import SwiftUI diff --git a/Template/Components/ShareSheetView.swift b/Template/Components/ShareSheetView.swift index 71d951b..b0e8d13 100644 --- a/Template/Components/ShareSheetView.swift +++ b/Template/Components/ShareSheetView.swift @@ -1,10 +1,3 @@ -// -// ShareSheetView.swift -// Template -// -// Created by Marcel Hoppe on 21.07.23. -// - import SwiftUI import UIKit @@ -29,7 +22,11 @@ struct ShareSheetView: UIViewControllerRepresentable { func makeUIViewController(context _: Context) -> UIActivityViewController { let controller = UIActivityViewController(activityItems: activityItems, applicationActivities: applicationActivities) controller.excludedActivityTypes = excludedActivityTypes - controller.completionWithItemsHandler = callback + + controller.completionWithItemsHandler = { activityType, completed, returnedItems, error in + callback?(activityType, completed, returnedItems, error) + } + return controller } diff --git a/Template/Components/StarsView.swift b/Template/Components/StarsView.swift new file mode 100644 index 0000000..c788f2e --- /dev/null +++ b/Template/Components/StarsView.swift @@ -0,0 +1,18 @@ +import SwiftUI + +struct StarsView: View { + var count: Int = 5 + var font: Font = .largeTitle + var spacing: CGFloat = 8 + + var body: some View { + HStack(spacing: spacing) { + ForEach(0 ..< count, id: \.self) { _ in + Image(systemName: "star.fill") + .font(font) + .foregroundColor(.yellow) + } + } + .dynamicTypeSize(...DynamicTypeSize.accessibility2) + } +} diff --git a/Template/Components/TitleSubtitleRow.swift b/Template/Components/TitleSubtitleRow.swift index 7bc225b..41b97f2 100644 --- a/Template/Components/TitleSubtitleRow.swift +++ b/Template/Components/TitleSubtitleRow.swift @@ -1,40 +1,41 @@ -// -// TitleSubtitleRow.swift -// Template -// -// Created by Marcel Hoppe on 21.07.23. -// - import SwiftUI struct TitleSubtitleRow: View { let systemName: String? - let title: String + let title: Text let subtitle: String? let showArrow: Bool + let imageGradient: [Color] - init(systemName: String? = nil, title: String, subtitle: String? = nil, showArrow: Bool = false) { + init(systemName: String? = nil, + title: LocalizedStringResource, + subtitle: String? = nil, + showArrow: Bool = false, + imageGradient: [Color] = []) { self.systemName = systemName - self.title = title + self.title = Text(title) self.subtitle = subtitle self.showArrow = showArrow + self.imageGradient = imageGradient } - var body: some View { - HStack(alignment: .firstTextBaseline) { - if let systemName = systemName { - Label(title, systemImage: systemName) - } else { - Text(title) - } - - Spacer() - - if let subtitle = subtitle { - Text(subtitle) - .foregroundColor(.secondary) - } + init(systemName: String? = nil, + verbatimTitle: String, + subtitle: String? = nil, + showArrow: Bool = false, + imageGradient: [Color] = []) { + self.systemName = systemName + title = Text(verbatim: verbatimTitle) + self.subtitle = subtitle + self.showArrow = showArrow + self.imageGradient = imageGradient + } + var body: some View { + TitleSubtitleRowWithContent(systemName: systemName, + title: title, + subtitle: subtitle, + imageGradient: imageGradient) { if showArrow { Spacer() .frame(width: 12) @@ -45,15 +46,58 @@ struct TitleSubtitleRow: View { } } -struct TitleSubtitleRow_Previews: PreviewProvider { - static var previews: some View { - Form { - TitleSubtitleRow(title: "Preview") - TitleSubtitleRow(title: "Preview", showArrow: true) - TitleSubtitleRow(title: "Preview", subtitle: "Preview") - TitleSubtitleRow(title: "Preview", subtitle: "Preview", showArrow: true) - TitleSubtitleRow(systemName: "globe", title: "Preview", subtitle: "Preview") - TitleSubtitleRow(systemName: "globe", title: "Preview", subtitle: "Preview", showArrow: true) +struct TitleSubtitleRowWithContent: View { + let systemName: String? + let title: Text + let subtitle: String? + let imageGradient: [Color] + @ViewBuilder var content: () -> Content + + init(systemName: String? = nil, + title: Text, + subtitle: String? = nil, + imageGradient: [Color] = [], + @ViewBuilder content: @escaping () -> Content) { + self.systemName = systemName + self.title = title + self.subtitle = subtitle + self.imageGradient = imageGradient + self.content = content + } + + var body: some View { + HStack { + Group { + if let systemName { + Label { + title + } icon: { + if !imageGradient.isEmpty { + Image(systemName: systemName) + .foregroundStyle(LinearGradient(colors: imageGradient, + startPoint: .topLeading, + endPoint: .bottomTrailing)) + } else { + Image(systemName: systemName) + } + } + } else { + title + } + } + .layoutPriority(1) + + Spacer() + + if let subtitle { + Text(subtitle) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.5) + } + + content() } + .truncationMode(.middle) } } diff --git a/Template/Extensions/Bundle.swift b/Template/Extensions/Bundle.swift new file mode 100644 index 0000000..3e0fcca --- /dev/null +++ b/Template/Extensions/Bundle.swift @@ -0,0 +1,15 @@ +import Foundation + +extension Bundle { + static var version: String? { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + } + + static var build: String? { + Bundle.main.infoDictionary?["CFBundleVersion"] as? String + } + + static var displayName: String? { + Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String + } +} diff --git a/Template/Extensions/Dictionary.swift b/Template/Extensions/Dictionary.swift new file mode 100644 index 0000000..31662ad --- /dev/null +++ b/Template/Extensions/Dictionary.swift @@ -0,0 +1,57 @@ +import Foundation + +// MARK: - Dictionary Merge Extension + +extension Dictionary { + /// Merges two dictionaries using the + operator. + /// In case of duplicate keys, values from the right dictionary take precedence. + /// + /// Example: + /// ``` + /// let dict1 = ["a": 1, "b": 2] + /// let dict2 = ["b": 3, "c": 4] + /// let merged = dict1 + dict2 // ["a": 1, "b": 3, "c": 4] + /// ``` + static func + (lhs: [Key: Value], rhs: [Key: Value]) -> [Key: Value] { + var result = lhs + // Using the built-in merge method is more efficient than forEach + result.merge(rhs) { _, new in new } + return result + } + + /// Merges another dictionary into this one using the += operator. + /// In case of duplicate keys, values from the right dictionary take precedence. + static func += (lhs: inout [Key: Value], rhs: [Key: Value]) { + lhs.merge(rhs) { _, new in new } + } + + /// Cleans an dictionary by removing empty strings and arrays. + func removeKeysWithEmptyValues() -> [Key: Value] { + var copy = self + forEach { key, value in + // filter out: "key": "" + if let stringValue = value as? String, stringValue.isEmpty { + copy.removeValue(forKey: key) + } + // filter out: "key": [] + if let arrayValue = value as? [Any], arrayValue.isEmpty { + copy.removeValue(forKey: key) + } + // filter out: "key": [:] + if let dictValue = value as? [AnyHashable: Any], dictValue.isEmpty { + copy.removeValue(forKey: key) + } + } + return copy as [Key: Value] + } +} + +// MARK: - Convenience Methods + +extension Dictionary where Key == String, Value == Any { + /// Merges multiple dictionaries together. + /// Later dictionaries override values from earlier ones for duplicate keys. + static func merge(_ dictionaries: [[String: Any]]) -> [String: Any] { + dictionaries.reduce([:]) { $0 + $1 } + } +} diff --git a/Template/Extensions/LocalizedStringResource.swift b/Template/Extensions/LocalizedStringResource.swift new file mode 100644 index 0000000..f9bb40e --- /dev/null +++ b/Template/Extensions/LocalizedStringResource.swift @@ -0,0 +1,61 @@ +import Foundation + +extension LocalizedStringResource { + static var delete: LocalizedStringResource { + .init("Delete", comment: "Button label to \"Delete\" some part of data or functionality in an app. Keep brief.") + } + + static var save: LocalizedStringResource { + .init("Save", comment: "Button label to \"Save\" some part of data or functionality in an app. Keep brief.") + } + + static var cancel: LocalizedStringResource { + .init("Cancel", comment: "Button label to stop or \"Cancel\" some app action or functionality. Keep brief.") + } + + static var edit: LocalizedStringResource { + .init("Edit", comment: "Button label to \"Edit\" as in change or modify some app functionality. Keep brief.") + } + + static var confirm: LocalizedStringResource { + .init("Confirm", comment: "Affirmative button, for example, confirming a selection. Keep brief.") + } + + static var back: LocalizedStringResource { + .init("Back", comment: "Button label to go back to the previous step or screen. Keep brief.") + } + + static var next: LocalizedStringResource { + .init("Next", comment: "Button label, usually used in a series of steps that need to be completed, for example during onboarding. Keep brief.") + } + + static var `continue`: LocalizedStringResource { + .init("Continue", comment: "Button label for an app action to proceed to the next step. Keep brief.") + } + + static var submit: LocalizedStringResource { + .init("Submit", comment: "Button label to submit an entered value, for example a referral code. Keep brief.") + } + + static var skip: LocalizedStringResource { + .init("Skip", comment: "Button label to skip some part of data or functionality in an app. Keep brief.") + } + + static var somethingWentWrong: LocalizedStringResource { + .init("Something went wrong, please try again.", + comment: "A generalised alert message shown to the user when something went wrong. Usually after an error occured making a network request.") + } + + static var ok: LocalizedStringResource { + .init("OK", comment: "Button label.") + } + + static var openSettings: LocalizedStringResource { + .init("Open settings", comment: "Button label. Usually on a button that when tapped opens the iOS app settings for the app.") + } + + static var checkOutApp: LocalizedStringResource { + .init("Check out \(Bundle.displayName ?? "this app") - I think you'll love it!", + comment: "Share sheet message showcasing the app for users the App Store link was shared with. Placeholder becomes the app name.") + } +} diff --git a/Template/Extensions/String.swift b/Template/Extensions/String.swift new file mode 100644 index 0000000..fbfc230 --- /dev/null +++ b/Template/Extensions/String.swift @@ -0,0 +1,14 @@ +import UIKit + +extension String { + /// Copies the string to the system pasteboard, with a light haptic as confirmation. + @MainActor + func copyToPasteboard(feedback: Bool = true) { + UIPasteboard.general.string = self + + if feedback { + let impact = UIImpactFeedbackGenerator(style: .light) + impact.impactOccurred() + } + } +} diff --git a/Template/Extensions/UIApplication.swift b/Template/Extensions/UIApplication.swift index 9892aea..2fa6b07 100644 --- a/Template/Extensions/UIApplication.swift +++ b/Template/Extensions/UIApplication.swift @@ -1,15 +1,8 @@ -// -// UIApplication.swift -// Template -// -// Created by Marcel Hoppe on 21.07.23. -// - import UIKit extension UIApplication { /// Opens settings within system settings. - class func openSystemSettings() { + class func openSystemAppSettings() { URL(string: UIApplication.openSettingsURLString)?.open() } diff --git a/Template/Extensions/UIDevice.swift b/Template/Extensions/UIDevice.swift new file mode 100644 index 0000000..dbf361a --- /dev/null +++ b/Template/Extensions/UIDevice.swift @@ -0,0 +1,13 @@ +import UIKit + +extension UIDevice { + /// Returns true if the current device is an iPad + static var isPad: Bool { + UIDevice.current.userInterfaceIdiom == .pad + } + + /// Returns true if the current device is an iPhone + static var isPhone: Bool { + UIDevice.current.userInterfaceIdiom == .phone + } +} diff --git a/Template/Extensions/URL.swift b/Template/Extensions/URL.swift index f0d55ea..f1a325b 100644 --- a/Template/Extensions/URL.swift +++ b/Template/Extensions/URL.swift @@ -1,10 +1,3 @@ -// -// URL.swift -// Template -// -// Created by Marcel Hoppe on 21.07.23. -// - import UIKit extension URL { @@ -25,18 +18,4 @@ extension URL { UIApplication.shared.open(self, options: [:]) { _ in } return true } - - // MARK: - Static URLs - - static var website: URL? { - URL(string: "https://www.hoppsen.com") - } - - static var termsOfUse: URL? { - URL(string: "https://www.hoppsen.com") - } - - static var privacyPolicy: URL? { - URL(string: "https://www.hoppsen.com") - } } diff --git a/Template/Extensions/UserDefaults.swift b/Template/Extensions/UserDefaults.swift new file mode 100644 index 0000000..2e21808 --- /dev/null +++ b/Template/Extensions/UserDefaults.swift @@ -0,0 +1,57 @@ +import Foundation + +extension UserDefaults { + /// Centralized enum for all UserDefaults keys. + /// + /// Add a case per key with a namespaced raw value, then read and write it through the + /// type-safe wrappers below (e.g. `UserDefaults.standard.set(true, forKey: .hasSeenIntro)`). + /// + /// When the app gains an extension (widget, share, …), add an app group plus a + /// `UserDefaults.sharedSuite` and split these keys into app-only vs shared. + enum Key: String { + /// Example key β€” replace with your own. Whether the user has completed the first launch. + case hasCompletedFirstLaunch = "app.hasCompletedFirstLaunch" + } + + // MARK: - Type-Safe Wrappers + + /// Set a value for a key + func set(_ value: Any?, forKey key: Key) { + set(value, forKey: key.rawValue) + } + + /// Get a boolean value for a key + func bool(forKey key: Key) -> Bool { + bool(forKey: key.rawValue) + } + + /// Get a string value for a key + func string(forKey key: Key) -> String? { + string(forKey: key.rawValue) + } + + /// Get an integer value for a key + func integer(forKey key: Key) -> Int { + integer(forKey: key.rawValue) + } + + /// Get a double value for a key + func double(forKey key: Key) -> Double { + double(forKey: key.rawValue) + } + + /// Get a data value for a key + func data(forKey key: Key) -> Data? { + data(forKey: key.rawValue) + } + + /// Get an object value for a key + func object(forKey key: Key) -> Any? { + object(forKey: key.rawValue) + } + + /// Remove a value for a key + func removeObject(forKey key: Key) { + removeObject(forKey: key.rawValue) + } +} diff --git a/Template/Info.plist b/Template/Info.plist index ff19fd1..456452f 100644 --- a/Template/Info.plist +++ b/Template/Info.plist @@ -20,6 +20,8 @@ $(MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + UILaunchScreen UIImageName diff --git a/Template/Models/ScreenState.swift b/Template/Models/ScreenState.swift new file mode 100644 index 0000000..ddb5671 --- /dev/null +++ b/Template/Models/ScreenState.swift @@ -0,0 +1,39 @@ +import Foundation + +enum ScreenState { + /// Screen loaded sucessfully + case normal + + /// The screen is currently loading. Show loading hud or loading button + case loading + + /// An error occured + case error(_ message: String?) + + // MARK: - Computed Properties + + var isNormal: Bool { + guard case .normal = self else { + return false + } + return true + } + + var isLoading: Bool { + guard case .loading = self else { + return false + } + return true + } + + var isError: Bool { + get { + guard case .error = self else { + return false + } + return true + } + // This variable needs a set in order to use it as a @Binding + set {} // swiftlint:disable:this unused_setter_value + } +} diff --git a/Template/Screens/About/AboutView.swift b/Template/Screens/About/AboutView.swift deleted file mode 100644 index 99ad86d..0000000 --- a/Template/Screens/About/AboutView.swift +++ /dev/null @@ -1,118 +0,0 @@ -// -// AboutView.swift -// Template -// -// Created by Marcel Hoppe on 21.07.23. -// - -import SwiftUI - -struct AboutView: View { - @StateObject var viewModel = AboutViewModel() - - var body: some View { - Form { - Section { - appIconView - } - .listRowBackground(Color.clear) - - Section { - TitleSubtitleRow(title: "Website", subtitle: URL.website?.host, showArrow: true) - .contentShape(Rectangle()) - .onTapGesture { - URL.website?.open() - } - - TitleSubtitleRow(title: "Twitter", subtitle: "@\(MainConstants.twitterUsername)", showArrow: true) - .contentShape(Rectangle()) - .onTapGesture { - viewModel.openTwitter() - } - } - - Section { - TitleSubtitleRow(title: "Open Source Licences", showArrow: true) - .contentShape(Rectangle()) - .onTapGesture { - UIApplication.openSystemSettings() - } - - TitleSubtitleRow(title: "Terms of Use", showArrow: true) - .contentShape(Rectangle()) - .onTapGesture { - viewModel.activeSheet = .termsOfUse - } - - TitleSubtitleRow(title: "Privacy Policy", showArrow: true) - .contentShape(Rectangle()) - .onTapGesture { - viewModel.activeSheet = .privacyPolicy - } - } - - Section { - TitleSubtitleRow(title: "Share Template", showArrow: true) - .foregroundColor(.accentColor) - .contentShape(Rectangle()) - .onTapGesture { - viewModel.activeSheet = .shareSheet - } - } - } - .navigationTitle("About") - .navigationBarTitleDisplayMode(.inline) - .sheet(item: $viewModel.activeSheet) { item in - switch item { - case .termsOfUse: - if let url = URL.termsOfUse { - SFSafariView(url: url).edgesIgnoringSafeArea(.bottom) - } - case .privacyPolicy: - if let url = URL.privacyPolicy { - SFSafariView(url: url).edgesIgnoringSafeArea(.bottom) - } - case .shareSheet: - shareSheet - } - } - } - - var shareSheet: some View { - ShareSheetView(activityItems: ["Check out Template\n\n\(MainConstants.appStoreLink)"]) { _, completed, _, _ in - guard completed else { - return - } - viewModel.activeSheet = nil - } - } - - var appIconView: some View { - HStack { - Spacer() - VStack { - ZStack { - RoundedRectangle(cornerRadius: 16) - .frame(width: 100, height: 100) - .foregroundColor(.purple) - Image(systemName: "doc.on.doc.fill") - .resizable() - .padding() - .aspectRatio(contentMode: .fit) - .frame(height: 80) - .foregroundColor(.white) - } - Text("Template 1.0.0 (0)") - .fontWeight(.semibold) - .foregroundColor(.primary) - } - Spacer() - } - } -} - -struct AboutView_Previews: PreviewProvider { - static var previews: some View { - AboutView() - } -} diff --git a/Template/Screens/About/AboutViewModel.swift b/Template/Screens/About/AboutViewModel.swift deleted file mode 100644 index 2976ac3..0000000 --- a/Template/Screens/About/AboutViewModel.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// AboutViewModel.swift -// Template -// -// Created by Marcel Hoppe on 21.07.23. -// - -import UIKit - -class AboutViewModel: ObservableObject { - enum ActiveSheet: Identifiable { - case termsOfUse, privacyPolicy, shareSheet - - var id: Int { - hashValue - } - } - - @Published var activeSheet: ActiveSheet? - - @MainActor func openTwitter() { - let application = UIApplication.shared - if let appURL = URL(string: MainConstants.twitterDeepLink), application.canOpenURL(appURL) { - application.open(appURL) - } else if let webURL = URL(string: MainConstants.twitterLink), application.canOpenURL(webURL) { - application.open(webURL) - } - } -} diff --git a/Template/Screens/Settings/SettingsView.swift b/Template/Screens/Settings/SettingsView.swift new file mode 100644 index 0000000..f12d1ab --- /dev/null +++ b/Template/Screens/Settings/SettingsView.swift @@ -0,0 +1,153 @@ +import SwiftUI +import UIKit + +struct SettingsView: View { + @State private var sheetControl: SheetControl? + + var body: some View { + Form { + preferencesSection + helpAndSupportSection + legalSection + shareSection + + versionAndBuildNumberView + } + .navigationTitle(Text("Settings", comment: "The title of the Settings screen and on the bottom tab bar. Keep it short.")) + .sheet(item: $sheetControl) { control in + switch control { + case let .openUrl(url): + SFSafariView(url: url).edgesIgnoringSafeArea(.bottom) + case .shareApp: + ShareSheetView(activityItems: shareItems) + } + } + } + + private func open(link: String) { + if let url = URL(string: link) { + sheetControl = .openUrl(url) + } + } +} + +// MARK: - Sections + +private extension SettingsView { + // MARK: - Preferences + + var preferencesSection: some View { + Section(header: Text("Preferences", comment: "Header of the preferences section that contains app settings like language.")) { + TitleSubtitleRow(systemName: "textformat", + title: LocalizedStringResource("Language", + comment: """ + Title of the language switching row within the Settings screen. Basically + \"Language\": \"Currently selected language, e.g English\". On the left side you have + \"Language\" and right side you have the currently selected language, e.g \"English\". + """), + subtitle: Locale.current.localizedString(forLanguageCode: Bundle.main.preferredLocalizations.first ?? ""), + showArrow: true) + .contentShape(Rectangle()) + .onTapGesture { + UIApplication.openSystemAppSettings() + } + } + } + + // MARK: - Help & Support + + var helpAndSupportSection: some View { + Section(header: Text("Help & Support", comment: "Header of the help and support section within the Settings screen.")) { + NavigationLink(destination: SupportView()) { + TitleSubtitleRow(systemName: "lifepreserver", + title: LocalizedStringResource("Contact Support", comment: "Title of the contact support row within the Settings screen.")) + } + } + } + + // MARK: - Legal + + var legalSection: some View { + Section(header: Text("Legal", comment: "Header of the legal section containing terms and privacy policy.")) { + TitleSubtitleRow(systemName: "doc.text", + title: LocalizedStringResource("Terms of Use", comment: "Title of the terms of use row within the Settings screen."), + showArrow: true) + .contentShape(Rectangle()) + .onTapGesture { + open(link: MainConstants.termsOfServiceLink) + } + + TitleSubtitleRow(systemName: "lock.doc", + title: LocalizedStringResource("Privacy Policy", comment: "Title of the privacy policy row within the Settings screen."), + showArrow: true) + .contentShape(Rectangle()) + .onTapGesture { + open(link: MainConstants.privacyPolicyLink) + } + + TitleSubtitleRow(systemName: "curlybraces.square", + title: LocalizedStringResource("Open Source Licences", + comment: "Title of the row that opens the app settings showing open source licences."), + showArrow: true) + .contentShape(Rectangle()) + .onTapGesture { + UIApplication.openSystemAppSettings() + } + } + } + + // MARK: - Share + + var shareItems: [Any] { + var items: [Any] = [String(localized: .checkOutApp)] + if let url = URL(string: MainConstants.appStoreLink) { + items.append(url) + } + return items + } + + var shareSection: some View { + Section { + TitleSubtitleRow(systemName: "square.and.arrow.up", + title: LocalizedStringResource("Share \(Bundle.displayName ?? "App")", comment: "Title for sharing the app with others."), + showArrow: false) + .foregroundStyle(Color.accentColor) + .contentShape(Rectangle()) + .onTapGesture { + sheetControl = .shareApp + } + } + } + + // MARK: - Version and Build Number + + var versionAndBuildNumberView: some View { + Section { + HStack { + Spacer() + Text("\(Bundle.version ?? "-") (\(Bundle.build ?? "-"))", + comment: "Used to display the version and build number as a string at the bottom of the Settings screen.") + Spacer() + } + .font(.footnote) + .foregroundStyle(Color.secondary) + .listRowBackground(Color.clear) + } + } +} + +// MARK: - SheetControl + +private enum SheetControl: Identifiable { + case openUrl(URL) + case shareApp + + var id: String { + switch self { + case let .openUrl(url): + return url.absoluteString + case .shareApp: + return "shareApp" + } + } +} diff --git a/Template/Screens/Settings/SupportView.swift b/Template/Screens/Settings/SupportView.swift new file mode 100644 index 0000000..5a72eef --- /dev/null +++ b/Template/Screens/Settings/SupportView.swift @@ -0,0 +1,61 @@ +import MessageUI +import SwiftUI + +struct SupportView: View { + let version: String = Bundle.version ?? "-" + let build: String = Bundle.build ?? "-" + + @State private var isShowingMailView = false + @State private var isMailUnavailableAlert = false + + var body: some View { + Form { + Section(header: Text("Information", comment: "Section header for various information in the settings."), + footer: Text("Tap the cell to copy the value", + comment: "Footer of the Version row. Tells the user to tap the row to copy the value.")) { + TitleSubtitleRow(title: .init("Version", comment: "App version label, displayed in settings."), subtitle: "\(version) (\(build))") + .contentShape(Rectangle()) + .onTapGesture { + "\(version) (\(build))".copyToPasteboard() + } + } + + Section { + Button(action: openMailComposer) { + HStack { + Image(systemName: "envelope") + Text("Contact Support", comment: "Button label that when pressed attempts to open Mail in order to get help.") + } + } + .alert(isPresented: $isMailUnavailableAlert) { + Alert(title: Text("Mail Not Configured", comment: "Title of an alert indicating that the mail app is not configured."), + message: Text("Mail is required for sending emails.\n\nPlease set up a Mail account on your device to continue.", + comment: "Message to show when the user tries to send an email but \"Mail\" is not configured"), + dismissButton: .default(Text(.ok))) + } + } + } + .navigationTitle(Text("Support", comment: "The title of the Support screen. Keep it short.")) + .sheet(isPresented: $isShowingMailView) { + // Not localized as we want the communication to be in English + MailComposeView(recipients: [MainConstants.supportEmail], + subject: "Support Request for \(Bundle.displayName ?? "App") \(version) (\(build))", + messageBody: """ + + + _________________________________________ + Please write your message above this line + + Version: \(version) (\(build)) + """) + } + } + + private func openMailComposer() { + if MFMailComposeViewController.canSendMail() { + isShowingMailView = true + } else { + isMailUnavailableAlert = true + } + } +} diff --git a/Template/Services/QuickActionsService.swift b/Template/Services/QuickActionsService.swift new file mode 100644 index 0000000..98ca239 --- /dev/null +++ b/Template/Services/QuickActionsService.swift @@ -0,0 +1,89 @@ +import SwiftUI +import UIKit + +/// Home Screen Quick Actions surfaced when the user long-presses the app icon. +/// +/// These are lightweight, retention-focused entry points that catch users at the +/// moment they are most likely about to remove the app: a feedback channel so +/// frustrated users vent to us instead of churning silently. Add further cases +/// for app-specific re-engagement hooks. +enum QuickAction: String, CaseIterable { + case feedback + + /// The `UIApplicationShortcutItem` type identifier, namespaced to the bundle. + var type: String { + "\(Bundle.main.bundleIdentifier ?? "app").quickaction.\(rawValue)" + } + + init?(type: String) { + guard let match = QuickAction.allCases.first(where: { $0.type == type }) else { + return nil + } + self = match + } + + private var title: String { + switch self { + case .feedback: + return String(localized: "Send Feedback", + comment: "Home Screen Quick Action (app icon long-press) that opens the in-app feedback flow. Keep very short.") + } + } + + private var subtitle: String? { + switch self { + case .feedback: + return String(localized: "We read every message", comment: "Subtitle for the 'Send Feedback' Home Screen Quick Action. Keep short.") + } + } + + private var systemImageName: String { + switch self { + case .feedback: + return "bubble.left.and.bubble.right.fill" + } + } + + var shortcutItem: UIApplicationShortcutItem { + UIApplicationShortcutItem(type: type, + localizedTitle: title, + localizedSubtitle: subtitle, + icon: UIApplicationShortcutIcon(systemImageName: systemImageName), + userInfo: nil) + } +} + +/// Bridges Home Screen Quick Actions (handled in the UIKit scene delegate) into the +/// SwiftUI view hierarchy. +/// +/// The scene delegate stores the triggered action in `pending`; the SwiftUI layer +/// observes it, routes it to the appropriate destination, then clears it. +/// +/// `@MainActor`: every access is on the main thread β€” the UIKit scene/app delegate callbacks that +/// set `pending` and the SwiftUI layer that observes it are both main-actor isolated. +@MainActor +@Observable +final class QuickActionsService { + static let shared = QuickActionsService() + + /// The action awaiting handling by the SwiftUI layer. Set by the scene delegate, + /// consumed (set back to `nil`) once the SwiftUI layer has routed it. + var pending: QuickAction? + + private init() {} + + /// Register the dynamic Home Screen Quick Actions. Call once on launch. + @MainActor + func registerShortcutItems() { + UIApplication.shared.shortcutItems = QuickAction.allCases.map(\.shortcutItem) + } + + /// Store a triggered shortcut for the SwiftUI layer to consume. + /// - Returns: `true` if the shortcut maps to a known action. + @discardableResult + func handle(_ shortcutItem: UIApplicationShortcutItem) -> Bool { + guard let action = QuickAction(type: shortcutItem.type) else { return false } + pending = action + return true + } +} diff --git a/Template/Services/README.md b/Template/Services/README.md new file mode 100644 index 0000000..b310f7b --- /dev/null +++ b/Template/Services/README.md @@ -0,0 +1,15 @@ +# Services + +Business logic and external integrations, one file (or folder, when split into extensions) per +service. Services expose a clean API for ViewModels to consume β€” either as caseless-enum static +facades or singletons (`FooService.shared`). Long-lived instances conform to `Sendable` (or +`@unchecked Sendable` with documented internal safety). + +Prefer initializer injection for testability (inject a `UserDefaults` suite/key, a path, a +configuration) over mock classes. Register instance-based services in `Utilities/Dependencies.swift` +so ViewModels consume `Dependencies.fooService` instead of instantiating their own. + +Bundled: + +* `QuickActionsService.swift` β€” bridges Home Screen Quick Actions (handled in the UIKit scene + delegate) into the SwiftUI view hierarchy via an `@Observable` singleton. diff --git a/Template/TemplateApp.swift b/Template/TemplateApp.swift index b2b2f04..c4ce331 100644 --- a/Template/TemplateApp.swift +++ b/Template/TemplateApp.swift @@ -1,17 +1,59 @@ -// -// TemplateApp.swift -// Template -// -// Created by Marcel Hoppe on 21.07.23. -// - import SwiftUI +import TipKit @main struct TemplateApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + + @State private var quickActions = QuickActionsService.shared + @State private var isShowingFeedback = false + + init() { + try? Tips.configure([.displayFrequency(.immediate)]) + } + var body: some Scene { WindowGroup { - AboutView() + NavigationStack { + SettingsView() + } + .task { + // A quick action captured during a cold launch (before the UI appeared) is + // stored by the scene delegate; replay it now that the scene is on screen. + if quickActions.pending != nil { + handleQuickAction(quickActions.pending) + } + } + // Home Screen Quick Actions (app icon long-press) for warm launches. + .onChange(of: quickActions.pending) { _, action in + handleQuickAction(action) + } + .sheet(isPresented: $isShowingFeedback) { + NavigationStack { + SupportView() + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + isShowingFeedback = false + } label: { + Text("Done", comment: "Button to dismiss the feedback/support sheet.") + } + } + } + } + } + } + } + + /// Route a Home Screen Quick Action (long-press app icon) to its in-app destination, + /// then consume it so it is not handled twice. + private func handleQuickAction(_ action: QuickAction?) { + guard let action else { return } + + switch action { + case .feedback: + quickActions.pending = nil + isShowingFeedback = true } } } diff --git a/Template/Utilities/Backport.swift b/Template/Utilities/Backport.swift new file mode 100644 index 0000000..2a78efc --- /dev/null +++ b/Template/Utilities/Backport.swift @@ -0,0 +1,458 @@ +// Source: https://github.com/superwall/iOS-Backports/blob/main/Sources/SwiftUIBackports/SwiftUIBackports.swift + +import ImagePlayground +import SwiftUI +#if canImport(WidgetKit) + import WidgetKit +#endif + +public struct Backport { + public let content: Content + + public init(_ content: Content) { + self.content = content + } +} + +@available(iOS 14, macOS 15, *) +public extension View { + var backport: Backport { Backport(self) } +} + +@available(iOS 14, macOS 15, *) +public extension ToolbarContent { + var backport: Backport { Backport(self) } +} + +// MARK: iOS 17 Extensions + +/// Backport for ContentTransition (iOS 17) +public enum BackDeployedContentTransition { + case identity + case opacity + case numericText +} + +@MainActor +@available(iOS 14, macOS 13, *) +public extension Backport where Content: View { + @ViewBuilder + func contentTransition(_ transition: BackDeployedContentTransition) -> some View { + if #available(iOS 17.0, *) { + switch transition { + case .identity: + content.contentTransition(.identity) + case .opacity: + content.contentTransition(.opacity) + case .numericText: + content.contentTransition(.numericText()) + } + } else { + content + } + } +} + +// MARK: iOS 18 Extensions + +@MainActor +@available(iOS 14, macOS 11, *) +public extension Backport where Content: View { + @ViewBuilder func presentationSizeForm() -> some View { + if #available(iOS 18, macOS 15, *) { + content.presentationSizing(.form) + } else { + content + } + } + + @ViewBuilder func zoom(sourceID: some Hashable, + in namespace: Namespace.ID) -> some View { + if #available(iOS 18.0, macOS 12, *) { + content + #if os(iOS) + .navigationTransition(.zoom(sourceID: sourceID, in: namespace)) + #endif + .interactiveDismissDisabled() + } else { + content + } + } + + @ViewBuilder func matchedTransitionSource(id: some Hashable, + in namespace: Namespace.ID) -> some View { + if #available(iOS 18.0, macOS 15, *) { + content.matchedTransitionSource(id: id, in: namespace) + } else { + content + } + } + + @ViewBuilder func imagePlayground(_ presented: Binding, + completion: @escaping (URL?) -> Void) -> some View { + if #available(iOS 18.1, macOS 15.1, *) { + if ImagePlaygroundViewController.isAvailable { + content + .imagePlaygroundSheet(isPresented: presented) { url in + completion(url) + } + } else { + content + } + } else { + content + } + } +} + +// MARK: iOS 26 Extensions + +@available(iOS 14, macOS 15, *) +public enum BackportGlass: Equatable, Sendable { + case regular + case clear + case identity + case tinted(Color?) + case interactive(isEnabled: Bool) + case tintedAndInteractive(color: Color?, isEnabled: Bool) + + // Default convenience + public static var regularInteractive: BackportGlass { + .tintedAndInteractive(color: nil, isEnabled: true) + } +} + +@available(iOS 26, macOS 26, *) +public extension BackportGlass { + var toGlass: Glass { + switch self { + case .regular: + return .regular + case .clear: + return .clear + case .identity: + return .identity + case let .tinted(color): + return .regular.tint(color) + case let .interactive(isEnabled): + return .regular.interactive(isEnabled) + case let .tintedAndInteractive(color, isEnabled): + return .regular.tint(color).interactive(isEnabled) + } + } +} + +public enum BackportGlassEffectTransition: Equatable, Sendable { + case identity + case materialize +} + +@available(iOS 26, macOS 26, *) +public extension BackportGlassEffectTransition { + var toTransition: GlassEffectTransition { + switch self { + case .identity: + return .identity + case .materialize: + return .materialize + } + } +} + +public enum BackportScrollEdgeEffectStyle: Hashable, Sendable { + case automatic + case hard + case soft +} + +@available(iOS 26.0, macOS 26, *) +public extension BackportScrollEdgeEffectStyle { + var toStyle: ScrollEdgeEffectStyle { + switch self { + case .automatic: return .automatic + case .hard: return .hard + case .soft: return .soft + } + } +} + +@available(iOS 26.0, macOS 26, *) +public extension BackportSymbolColorRenderingMode { + var toMode: SymbolColorRenderingMode { + switch self { + case .flat: return .flat + case .gradient: return .gradient + } + } +} + +public enum BackportSymbolColorRenderingMode: Equatable, Sendable { + case flat + case gradient +} + +public enum BackportSymbolVariableValueMode: Equatable, Sendable { + case color + case draw +} + +@available(iOS 26.0, macOS 26, *) +public extension BackportSymbolVariableValueMode { + var toMode: SymbolVariableValueMode { + switch self { + case .color: return .color + case .draw: return .draw + } + } +} + +public enum BackportTabBarMinimizeBehavior: Hashable, Sendable { + case automatic + case onScrollDown + case onScrollUp + case never +} + +public enum BackportSearchToolbarBehavior: Hashable, Sendable { + case automatic + case minimize +} + +@available(iOS 26.0, macOS 26, *) +public extension BackportTabBarMinimizeBehavior { + var toBehavior: TabBarMinimizeBehavior { + switch self { + case .automatic: + return .automatic + #if os(iOS) + case .onScrollDown: + return .onScrollDown + case .onScrollUp: + return .onScrollUp + case .never: + return .never + #else + default: + return .automatic + #endif + } + } +} + +@MainActor +@available(iOS 14, macOS 15, *) +public extension Backport where Content: View { + @ViewBuilder func presentationBackground(in shape: some ShapeStyle = Material.thin) -> some View { + if #available(iOS 26.0, macOS 26, *) { + content + } else if #available(macOS 13.3, *) { + content.presentationBackground(shape) + } else { + content + } + } + + @ViewBuilder func glassEffectTransition(_ transition: BackportGlassEffectTransition) -> some View { + if #available(iOS 26.0, macOS 26, *) { + content.glassEffectTransition(transition.toTransition) + } else { + content + } + } + + @ViewBuilder func glassEffect(_ backportGlass: BackportGlass = .regular, + in shape: some Shape = Capsule()) -> some View { + if #available(iOS 26.0, macOS 26, *) { + content.glassEffect(backportGlass.toGlass, in: shape) + } else { + content.clipShape(shape) + } + } + + @ViewBuilder func glassEffect(_ backportGlass: BackportGlass = .regular, + in shape: some Shape = Capsule(), + fallbackBackground: some ShapeStyle) -> some View { + if #available(iOS 26.0, macOS 26, *) { + content.glassEffect(backportGlass.toGlass, in: shape) + } else { + if #available(macOS 12.0, *) { + content.background(fallbackBackground, in: shape) + } else { + content + } + } + } + + @ViewBuilder func glassEffectContainer(spacing: CGFloat? = nil) -> some View { + if #available(iOS 26.0, macOS 26, *) { + GlassEffectContainer(spacing: spacing) { content } + } else { + content + } + } + + @ViewBuilder func glassEffectUnion(id: (some Hashable & Sendable)?, + namespace: Namespace.ID) -> some View { + if #available(iOS 26.0, macOS 26, *) { + content.glassEffectUnion(id: id, namespace: namespace) + } else { + content + } + } + + @ViewBuilder func glassButtonStyle(fallbackStyle: some PrimitiveButtonStyle = DefaultButtonStyle()) -> some View { + if #available(iOS 26.0, macOS 26, *) { + content.buttonStyle(.glass) + } else { + content.buttonStyle(fallbackStyle) + } + } + + @ViewBuilder func glassProminentButtonStyle() -> some View { + if #available(iOS 26.0, macOS 26, *) { + content.buttonStyle(.glassProminent) + } else { + content.buttonStyle(GlassProminentFallbackButtonStyle()) + } + } + + @ViewBuilder func backgroundExtensionEffect() -> some View { + if #available(iOS 26.0, macOS 26, *) { + content.backgroundExtensionEffect() + } else { + content + } + } + + @ViewBuilder func scrollEdgeEffectStyle(_ style: BackportScrollEdgeEffectStyle?, + for edges: Edge.Set) -> some View { + if #available(iOS 26.0, macOS 26, *) { + content.scrollEdgeEffectStyle(style?.toStyle, for: edges) + } else { + content + } + } + + @ViewBuilder func scrollEdgeEffectHidden(_ hidden: Bool = true, + for edges: Edge.Set = .all) -> some View { + if #available(iOS 26.0, macOS 26, *) { + content.scrollEdgeEffectHidden(hidden, for: edges) + } else { + content + } + } + + @ViewBuilder func glassEffectID(_ id: (some Hashable & Sendable)?, + in namespace: Namespace.ID) -> some View { + if #available(iOS 26.0, macOS 26, *) { + content.glassEffectID(id, in: namespace) + } else { + content + } + } + + @ViewBuilder func symbolColorRenderingMode(_ mode: BackportSymbolColorRenderingMode?) -> some View { + if #available(iOS 26.0, macOS 26, *) { + content.symbolColorRenderingMode(mode?.toMode) + } else { + content + } + } + + @ViewBuilder func symbolVariableValueMode(_ mode: BackportSymbolVariableValueMode?) -> some View { + if #available(iOS 26.0, macOS 26, *) { + content.symbolVariableValueMode(mode?.toMode) + } else { + content + } + } + + @ViewBuilder func tabViewBottomAccessory(@ViewBuilder content: () -> some View) -> some View { + #if os(macOS) + self.content + #else + if #available(iOS 26.0, *) { + self.content.tabViewBottomAccessory(content: content) + } else { + self.content + } + #endif + } + + @ViewBuilder func tabBarMinimizeBehavior(_ behavior: BackportTabBarMinimizeBehavior) -> some View { + if #available(iOS 26.0, macOS 26, *) { + content.tabBarMinimizeBehavior(behavior.toBehavior) + } else { + content + } + } + + @ViewBuilder func listSectionMargins(_ edges: Edge.Set = .all, _ length: CGFloat?) -> some View { + if #available(iOS 26.0, macOS 15, *) { + #if os(iOS) + content.listSectionMargins(edges, length) + #else + content + #endif + } else { + content + } + } + + @ViewBuilder func safeAreaBar(edge: VerticalEdge, + alignment: HorizontalAlignment = .center, + spacing: CGFloat? = nil, + @ViewBuilder content: () -> V) -> some View { + if #available(iOS 26.0, macOS 26, *) { + self.content.safeAreaBar(edge: edge, alignment: alignment, spacing: spacing, content: content) + } else { + self.content.safeAreaInset(edge: edge, alignment: alignment, spacing: spacing, content: content) + } + } + + @ViewBuilder func searchToolbarBehavior(_ behavior: BackportSearchToolbarBehavior) -> some View { + if #available(iOS 26.0, macOS 26, *) { + switch behavior { + case .automatic: + content.searchToolbarBehavior(.automatic) + case .minimize: + #if os(macOS) + content.searchToolbarBehavior(.automatic) + #else + content.searchToolbarBehavior(.minimize) + #endif + } + } else { + content + } + } +} + +@MainActor +@available(iOS 14, macOS 15, *) +public extension Backport where Content: ToolbarContent { + @ToolbarContentBuilder + func sharedToolBarBackgroundVisibility(_ visibility: Visibility) -> some ToolbarContent { + if #available(iOS 26.0, macOS 26, *) { + content.sharedBackgroundVisibility(visibility) + } else { + content + } + } +} + +// MARK: - Fallback Button Styles + +@available(iOS 14, macOS 15, *) +public struct GlassProminentFallbackButtonStyle: PrimitiveButtonStyle { + public init() {} + + public func makeBody(configuration: Configuration) -> some View { + Button(action: configuration.trigger) { + configuration.label + .foregroundStyle(.white) + .background(.tint, in: Capsule()) + } + .buttonStyle(.plain) + } +} diff --git a/Template/Utilities/Dependencies.swift b/Template/Utilities/Dependencies.swift new file mode 100644 index 0000000..d89fcf3 --- /dev/null +++ b/Template/Utilities/Dependencies.swift @@ -0,0 +1,16 @@ +import Foundation + +/// Lightweight static container for instance-based services. +/// +/// ViewModels consume `Dependencies.fooService` instead of instantiating their own, which keeps +/// construction (and test injection points) in one place. Caseless-enum static facades and +/// `.shared` singletons are called directly and do not need an entry here. +final class Dependencies { + // MARK: - Thread-Safe Services + + // static let fooService: FooService = .init() + + // MARK: - Singleton Services + + // static var barManager: BarManager { .shared } +} diff --git a/Template/Utilities/DialogTypes.swift b/Template/Utilities/DialogTypes.swift new file mode 100644 index 0000000..005bc3a --- /dev/null +++ b/Template/Utilities/DialogTypes.swift @@ -0,0 +1,53 @@ +import SwiftUI + +// MARK: - Alert Types + +/// Central definition of every alert the app can present. +/// +/// ViewModels expose `var alert: AlertType?` and views attach `.alert($viewModel.alert)`. +/// Add one case per alert, carrying its actions as associated closures. +enum AlertType: Identifiable { + case error(message: LocalizedStringResource) + + var id: String { + switch self { + case .error: return "error" + } + } +} + +extension View { + func alert(_ alert: Binding) -> some View { + self.alert(alertTitle(alert.wrappedValue), + isPresented: Binding(get: { alert.wrappedValue != nil }, + set: { if !$0 { alert.wrappedValue = nil } })) { + switch alert.wrappedValue { + case .error: + Button(role: .cancel) {} label: { + Text(.ok) + } + case .none: + EmptyView() + } + } message: { + switch alert.wrappedValue { + case let .error(message): + Text(message) + case .none: + EmptyView() + } + } + } + + private func alertTitle(_ type: AlertType?) -> String { + switch type { + case .error: + return String(localized: "Error", comment: """ + Generic alert title for error conditions. + Keep brief. + """) + case .none: + return "" + } + } +} diff --git a/Template/Utilities/Globals.swift b/Template/Utilities/Globals.swift index 3855dd5..5a14f47 100644 --- a/Template/Utilities/Globals.swift +++ b/Template/Utilities/Globals.swift @@ -1,10 +1,3 @@ -// -// Globals.swift -// Template -// -// Created by Marcel Hoppe on 21.07.23. -// - import UIKit typealias Completion = () -> Void @@ -17,9 +10,12 @@ struct MainConstants { static let appStoreLink = "https://apps.apple.com/app//\(appId)" // <- CHANGE static let appStoreDeepLink = "itms-apps://apple.com/app/\(appId)" - // MARK: - Twitter + // MARK: - Legal URLs + + static let termsOfServiceLink = "https://example.com/terms" // <- CHANGE + static let privacyPolicyLink = "https://example.com/privacy" // <- CHANGE + + // MARK: - Support - static let twitterUsername = "hoppsen1" // <- CHANGE - static let twitterLink = "https://twitter.com/\(twitterUsername)" - static let twitterDeepLink = "twitter://user?screen_name=\(twitterUsername)" + static let supportEmail = "support@example.com" // <- CHANGE } diff --git a/Template/Utilities/KeyboardListener.swift b/Template/Utilities/KeyboardListener.swift new file mode 100644 index 0000000..48df2ae --- /dev/null +++ b/Template/Utilities/KeyboardListener.swift @@ -0,0 +1,17 @@ +import Combine +import UIKit + +class KeyboardListener { + static var keyboardPublisher: AnyPublisher<(isShowing: Bool, height: CGFloat), Never> { + let keyboardWillShowNotificationPublisher = NotificationCenter.default + .publisher(for: UIResponder.keyboardWillShowNotification) + .compactMap { $0.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue } + .map { (true, $0.cgRectValue.height) } + let keyboardWillHideNotification = NotificationCenter.default + .publisher(for: UIResponder.keyboardWillHideNotification) + .map { _ in (false, 0 as CGFloat) } + return keyboardWillShowNotificationPublisher.merge(with: keyboardWillHideNotification) + .map { (isShowing: $0.0, height: $0.1) } + .eraseToAnyPublisher() + } +} diff --git a/Template/Utilities/Logger.swift b/Template/Utilities/Logger.swift new file mode 100644 index 0000000..54ed9a3 --- /dev/null +++ b/Template/Utilities/Logger.swift @@ -0,0 +1,53 @@ +import OSLog + +final class Logger: @unchecked Sendable { + private let category: String + private let logger: os.Logger + + init(category: String) { + self.category = category + logger = os.Logger(subsystem: Bundle.main.bundleIdentifier!, category: category) + } + + /// Use this method to write messages using the default log level to both the in-memory and on-disk log stores. + func log(_ message: String) { + // os.Logger is already thread-safe, no need for additional synchronization + let category = category.uppercased() + logger.log("[\(category)]: \(message)") + } + + /// Use this method to write messages with the debug log level to the in-memory log store only. + func debug(_ message: String) { + let category = category.uppercased() + logger.debug("[\(category)]: \(message)") + } + + /// Use this method to write messages with the info log level to the in-memory log store only. + func info(_ message: String) { + let category = category.uppercased() + logger.info("[\(category)]: \(message)") + } + + /// Use this method to write messages with the error log level to both the in-memory and on-disk log stores. + func error(_ message: String) { + let category = category.uppercased() + logger.error("[\(category)]: \(message)") + } + + func warning(_ message: String) { + let category = category.uppercased() + logger.warning("[\(category)]: \(message)") + } +} + +// Use a private extension to ensure all loggers are initialized at once +private extension Logger { + static let _general = Logger(category: "general") + static let _userDefaults = Logger(category: "user-defaults") +} + +// Public extension with computed properties to ensure thread-safe access +extension Logger { + static var general: Logger { _general } + static var userDefaults: Logger { _userDefaults } +} diff --git a/Template/Utilities/ViewModifiers.swift b/Template/Utilities/ViewModifiers.swift new file mode 100644 index 0000000..fff16f4 --- /dev/null +++ b/Template/Utilities/ViewModifiers.swift @@ -0,0 +1,42 @@ +import SwiftUI + +extension View { + /// Shows a three dots loading animation. Should only be used on controls, such as `PrimaryButton`. + /// + /// Example: + /// + /// PrimaryButton(L10n.awesome, screenState: viewModel.state) { + /// print("Do something!") + /// } + /// + /// - Parameters: + /// - screenState: Shows and hides the loading animation based off of the `screenState`. + func loadingThreeDots(screenState: ScreenState?) -> some View { + modifier(LoadingThreeDotsModifier(screenState: screenState)) + } + + /// Hide or show the view based on a boolean value. + /// + /// Example for visibility: + /// + /// Text("Label") + /// .isHidden(true) + /// + /// Example for complete removal: + /// + /// Text("Label") + /// .isHidden(true, remove: true) + /// + /// - Parameters: + /// - hidden: Set to `false` to show the view. Set to `true` to hide the view. + /// - remove: Boolean value indicating whether or not to remove the view. + @ViewBuilder func isHidden(_ hidden: Bool, remove: Bool = false) -> some View { + if hidden { + if !remove { + self.hidden() + } + } else { + self + } + } +} diff --git a/TemplateTests/TemplateTests.swift b/TemplateTests/TemplateTests.swift index 709b112..45421e7 100644 --- a/TemplateTests/TemplateTests.swift +++ b/TemplateTests/TemplateTests.swift @@ -1,10 +1,3 @@ -// -// TemplateTests.swift -// TemplateTests -// -// Created by Marcel Hoppe on 26.07.23. -// - import XCTest final class TemplateTests: XCTestCase { diff --git a/TemplateUITests/TemplateUITests.swift b/TemplateUITests/TemplateUITests.swift index 1997c32..d1cf7d2 100644 --- a/TemplateUITests/TemplateUITests.swift +++ b/TemplateUITests/TemplateUITests.swift @@ -1,10 +1,3 @@ -// -// TemplateUITests.swift -// TemplateUITests -// -// Created by Marcel Hoppe on 26.07.23. -// - import XCTest final class TemplateUITests: XCTestCase { diff --git a/TemplateUITests/TemplateUITestsLaunchTests.swift b/TemplateUITests/TemplateUITestsLaunchTests.swift index f7dbd03..0f2e2c3 100644 --- a/TemplateUITests/TemplateUITestsLaunchTests.swift +++ b/TemplateUITests/TemplateUITestsLaunchTests.swift @@ -1,14 +1,7 @@ -// -// TemplateUITestsLaunchTests.swift -// TemplateUITests -// -// Created by Marcel Hoppe on 26.07.23. -// - import XCTest final class TemplateUITestsLaunchTests: XCTestCase { - override class var runsForEachTargetApplicationUIConfiguration: Bool { + override static var runsForEachTargetApplicationUIConfiguration: Bool { true } diff --git a/fastlane/.env b/fastlane/.env index 0e43d40..edf7622 100644 --- a/fastlane/.env +++ b/fastlane/.env @@ -1 +1,2 @@ -APP_IDENTIFIER = 'com.hoppsen.template' \ No newline at end of file +APP_IDENTIFIER = 'com.hoppsen.template' +TARGET = 'Template' diff --git a/fastlane/.env.prd b/fastlane/.env.prd index 7d0b457..c74910c 100644 --- a/fastlane/.env.prd +++ b/fastlane/.env.prd @@ -1 +1,14 @@ -SCHEME = 'Template' \ No newline at end of file +SCHEME = 'Template' + +# Build +GYM_OUTPUT_NAME = 'Template.ipa' + +# Deploy +PILOT_SKIP_WAITING_FOR_BUILD_PROCESSING = false +PILOT_DISTRIBUTE_EXTERNAL = true +PILOT_NOTIFY_DISTRIBUTE_EXTERNAL = true +PILOT_GROUPS = 'Family and Friends' +PILOT_REJECT_BUILD_WAITING_FOR_REVIEW = true +PILOT_SUBMIT_BETA_REVIEW = true + +PILOT_CHANGELOG = "Welcome Beta Testers!" diff --git a/fastlane/.env.stg b/fastlane/.env.stg index 0c26ba5..701e015 100644 --- a/fastlane/.env.stg +++ b/fastlane/.env.stg @@ -1 +1,12 @@ -SCHEME = 'Template Debug' \ No newline at end of file +SCHEME = 'Template Staging' + +# Build +APP_ICON_TAG = 'STG' +GYM_OUTPUT_NAME = 'Template-Staging.ipa' + +# Deploy +PILOT_SKIP_WAITING_FOR_BUILD_PROCESSING = true +PILOT_DISTRIBUTE_EXTERNAL = false +PILOT_NOTIFY_DISTRIBUTE_EXTERNAL = false +PILOT_REJECT_BUILD_WAITING_FOR_REVIEW = false +PILOT_SUBMIT_BETA_REVIEW = false diff --git a/fastlane/Definitionsfile b/fastlane/Definitionsfile index d924a85..df0f6aa 100644 --- a/fastlane/Definitionsfile +++ b/fastlane/Definitionsfile @@ -1,36 +1,97 @@ -##### General ##### - -def is_installed?(tool) - begin - sh("which #{tool}") - return true - rescue - UI.error("'#{tool}' not found!\nPlease make sure '#{tool}' exists.") - return false +##### App Icon ##### + +def update_app_icon(tag) + UI.header('Step: update_app_icon') + + appIconPath = File.expand_path("../Template/Assets/AppIcon.icon") + appIconSetPath = File.expand_path("../Template/Assets/Assets.xcassets/AppIcon.appiconset") + newAppIconPath = File.expand_path("../fastlane/assets/AppIcon-#{tag}.icon") + newAppIconSetPath = File.expand_path("../fastlane/assets/AppIcon-#{tag}.appiconset") + + UI.message('Replacing icon...') + + # Handle .icon file + if File.exist?(newAppIconPath) + sh("rm -rf #{appIconPath}") + sh("mv #{newAppIconPath} #{appIconPath}") + else + UI.message("New .icon not found: #{newAppIconPath}") end -end -def file_contains_regexp?(filename, regexp) - File.foreach(filename) do |line| - return true if line =~ regexp + # Handle .appiconset independently + if File.exist?(newAppIconSetPath) + sh("rm -rf #{appIconSetPath}") + sh("mv #{newAppIconSetPath} #{appIconSetPath}") + else + UI.message("New .appiconset not found: #{newAppIconSetPath}") end - return false end ##### SwiftGen ##### -def generate_localization_enum - strings = "#{LOCALIZATION_PATH}/en.lproj" - output = "#{LOCALIZATION_PATH}/L10n.swift" - sh("mint run swiftgen run strings #{strings} --output #{output} --templateName flat-swift5") -end - def generate_assets_enum - xcassets = "#{ASSETS_PATH}/Assets.xcassets" - output = "#{ASSETS_PATH}/Assets.swift" + assetsPath = File.expand_path('../Template/Assets') + xcassets = "#{assetsPath}/Assets.xcassets" + output = "#{assetsPath}/Assets.swift" sh("mint run swiftgen run xcassets #{xcassets} --output #{output} --templateName swift5") end +##### Version and Build Number ##### + +def export_to_github_env(key, value) + UI.header('Step: export_to_github_env') + + github_env_file = ENV['GITHUB_ENV'] + if github_env_file && File.exist?(github_env_file) + begin + File.open(github_env_file, 'a') do |file| + file.puts("#{key}=#{value}") + end + UI.message("Successfully wrote #{key}=#{value} to GITHUB_ENV file.") + rescue => e + UI.important("Warning: Failed to write to GITHUB_ENV file: #{e.message}") + end + else + UI.important("Warning: GITHUB_ENV environment variable is not set or the file does not exist. Skipping export.") + end +end + +def fetch_and_increment_build_number + UI.header('Step: fetch_and_increment_build_number') + + # Get bundle identifier from environment variable + bundle_id = ENV['APP_IDENTIFIER'] + if bundle_id.nil? || bundle_id.empty? + UI.error("APP_IDENTIFIER environment variable is required but not set") + raise "APP_IDENTIFIER environment variable must be set" + end + + # Generate SHA-256 hash of the bundle identifier + require 'digest' + hash = Digest::SHA256.hexdigest(bundle_id) + + UI.message("Using bundle identifier: #{bundle_id}") + UI.message("Generated hash: #{hash}") + + # https://increment.build/ id = SHA-256 hash of the string "" + uri = URI.parse("https://increment.build/#{hash}") + response = Net::HTTP.get_response(uri) + + if response.is_a?(Net::HTTPSuccess) + build_number = response.body.strip + if build_number =~ /^\d+$/ + # Export the build number to the GitHub Actions environment + export_to_github_env('BUILD_NUMBER', build_number) + + return build_number + else + raise "Invalid build number format received" + end + else + raise "Failed to fetch build number" + end +end + ##### Rename ##### def ensure_project_was_renamed @@ -49,8 +110,8 @@ def rename_project(oldName, newName) # Rename folders and files # .github/workflows - # sh("sed -i '' 's|#{oldName}|#{newName}|g' ../.github/workflows/build.yml") - # sh("sed -i '' 's|#{oldName}|#{newName}|g' ../.github/workflows/releasing.yml") + sh("sed -i '' 's|#{oldName}|#{newName}|g' ../.github/workflows/build.yml") + sh("sed -i '' 's|#{oldName}|#{newName}|g' ../.github/workflows/releasing.yml") # Fastlane sh("sed -i '' 's|#{oldName}|#{newName}|g' ../fastlane/.env") @@ -58,13 +119,14 @@ def rename_project(oldName, newName) sh("sed -i '' 's|#{oldName}|#{newName}|g' ../fastlane/.env.prd") sh("sed -i '' 's|#{oldName}|#{newName}|g' ../fastlane/.env.stg") sh("sed -i '' 's|#{oldName}/Assets|#{newName}/Assets|g' ../fastlane/Definitionsfile") - # sh("sed -i '' 's|#{oldName.downcase}|#{newName.downcase}|g' ../fastlane/Deliverfile") + sh("sed -i '' 's|#{oldName.downcase}|#{newName.downcase}|g' ../fastlane/Deliverfile") sh("sed -i '' 's|#{oldName}|#{newName}|g' ../fastlane/Fastfile") - # sh("sed -i '' 's|#{oldName.downcase}|#{newName.downcase}|g' ../fastlane/Matchfile") + sh("sed -i '' 's|#{oldName.downcase}|#{newName.downcase}|g' ../fastlane/Matchfile") sh("sed -i '' 's|#{oldName}|#{newName}|g' ../fastlane/Setupfile") # Project file sh("mv ../#{oldName}.xcodeproj/xcshareddata/xcschemes/#{oldName}.xcscheme ../#{oldName}.xcodeproj/xcshareddata/xcschemes/#{newName}.xcscheme") + sh("mv \"../#{oldName}.xcodeproj/xcshareddata/xcschemes/#{oldName} Staging.xcscheme\" \"../#{oldName}.xcodeproj/xcshareddata/xcschemes/#{newName} Staging.xcscheme\"") sh("mv ../#{oldName}.xcodeproj ../#{newName}.xcodeproj") ## Template @@ -85,17 +147,23 @@ def rename_project(oldName, newName) sh("rm -rf ../#{oldName}UITests") # Search and Replace occurrences of + # CLAUDE.md is optional in this public template; only rewrite it if present. + sh("sed -i '' 's|#{oldName}|#{newName}|g' ../CLAUDE.md") if File.exist?('../CLAUDE.md') sh("sed -i '' 's|#{oldName}.xcodeproj|#{newName}.xcodeproj|g' ../README.md") sh("sed -i '' 's|#{oldName}.xcodeproj|#{newName}.xcodeproj|g' ../hooks/pre-commit") sh("sed -i '' 's|#{oldName}|#{newName}|g' ../#{newName}/Info.plist") sh("sed -i '' 's|#{oldName}|#{newName}|g' ../scripts/generate-swiftlint-filelist.sh") sh("sed -i '' 's|#{oldName}|#{newName}|g' ../#{newName}.xcodeproj/project.pbxproj") sh("sed -i '' 's|#{oldName}|#{newName}|g' ../#{newName}.xcodeproj/xcshareddata/xcschemes/#{newName}.xcscheme") + sh("sed -i '' 's|#{oldName}|#{newName}|g' \"../#{newName}.xcodeproj/xcshareddata/xcschemes/#{newName} Staging.xcscheme\"") sh("find ../#{newName}* -name '*.swift' -print0 | xargs -0 sed -i '' 's|#{oldName}|#{newName}|g'") # Remove build phases results to prevent compile issues with SwiftLint file lists sh("rm -rf ../.build/build_phases") + + # Keep SwiftLint exclude in sync: rename only the exact Assets.swift path (do not touch '- templates') + sh("sed -i '' 's|#{oldName}/Assets/Assets.swift|#{newName}/Assets/Assets.swift|g' ../.swiftlint.yml") end def update_marketing_version(new_name) diff --git a/fastlane/Deliverfile b/fastlane/Deliverfile new file mode 100644 index 0000000..935e1f8 --- /dev/null +++ b/fastlane/Deliverfile @@ -0,0 +1,23 @@ +team_id('123456789') # <- CHANGE + +app_identifier("com.hoppsen.template") + +# Source: https://github.com/fastlane/fastlane/blob/master/spaceship/lib/spaceship/tunes/app_submission.rb +submission_information({ + add_id_info_serves_ads: false, + add_id_info_tracks_action: false, + add_id_info_tracks_install: false, + add_id_info_uses_idfa: false, + content_rights_contains_third_party_content: false, + content_rights_has_rights: false, + # BELOW IS NOT NEEDED. JUST LEFT IT FOR REFERENCE + # export_compliance_available_on_french_store: false, + # export_compliance_contains_proprietary_cryptography: false, + # export_compliance_contains_third_party_cryptography: false, + # export_compliance_is_exempt: false, + # export_compliance_uses_encryption: false, + # export_compliance_app_type: nil, + # export_compliance_encryption_updated: false, + # export_compliance_compliance_required: false, + # export_compliance_platform: 'ios', +}) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index ab7f80f..630dd23 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -1,5 +1,5 @@ -ruby_version '3.0.0' -fastlane_version '2.221.0' +ruby_version '3.3.0' +fastlane_version '2.225.0' default_platform(:ios) import 'Setupfile' @@ -8,7 +8,6 @@ import 'Definitionsfile' # Global constants, which are not changing based on app or environment. # For anything else please use Dotenv, such as `.env.stg` or `.env.prd` files. PROJECT = 'Template.xcodeproj' -ASSETS_PATH = File.expand_path('../Template/Assets') OUTPUT_DIRECTORY = File.expand_path('../fastlane/output') DERIVED_DATA_PATH = File.expand_path('../fastlane/derivedData') SOURCE_PACKAGES_PATH = File.expand_path('../fastlane/sourcePackages') @@ -16,10 +15,37 @@ SOURCE_PACKAGES_PATH = File.expand_path('../fastlane/sourcePackages') platform :ios do before_all do |lane| ensure_bundle_exec + + # Ensures the keychain exists before calling setup_jenkins + create_keychain( + name: ENV['MATCH_KEYCHAIN_NAME'], + password: ENV['MATCH_KEYCHAIN_PASSWORD'], + default_keychain: false, + unlock: true, + timeout: 0, + add_to_search_list: true, + ) if is_ci + + # Sets up any CI/CD environment, regardless of the provider + setup_jenkins( + output_directory: OUTPUT_DIRECTORY, + derived_data_path: DERIVED_DATA_PATH, + unlock_keychain: true, + keychain_path: ENV['MATCH_KEYCHAIN_NAME'], + keychain_password: ENV['MATCH_KEYCHAIN_PASSWORD'], + set_default_keychain: false, + ) end error do |lane, exception, options| UI.error("Options: #{options.inspect}") + + if is_ci + UI.error("ENV:") + ENV.each do |key, value| + UI.error("#{key}=#{value}") + end + end end # MARK: - Lint and Format @@ -61,4 +87,178 @@ platform :ios do generate_assets_enum end end -end \ No newline at end of file + + # MARK: - Build + + desc 'Updates version number' + desc '#### Example:' + desc "```\nbundle exec fastlane updateVersion\n```" + desc '#### Options:' + desc ' * **`patch_number`**: Patch number to be appended to the version number in the following format: .. Defaults to `0`. Increments the patch number if the current week is the same as the latest production version.' + lane :updateVersion do |options| + current_date = Time.new + year = current_date.strftime("%G") # To retrieve the ISO 8601 year corresponding to the week number for potential year change issues. + week = current_date.strftime("%-V") # - removes leading 0 + + begin + # Fetch the latest production version + latest_version = get_app_store_version_number(bundle_id: ENV['APP_IDENTIFIER'], country: 'us') + latest_year, latest_week, latest_patch = latest_version.split('.').map(&:to_i) + UI.message("Latest production version: #{latest_version}") + + if latest_year.to_s == year && latest_week.to_s == week + # We're still in the same week as the latest production version + patch = [options[:patch_number].to_i, latest_patch + 1].max + UI.important("We're still in the same week as the latest production version, using the maximum patch version: #{patch}") + else + # If it's a new week, start with patch 0 or use the provided patch number + patch = options[:patch_number] || 0 + end + rescue => e + UI.important("Error fetching latest version from App Store: (#{e.message}). Using default versioning.") + # If we can't fetch the version (e.g., app not released yet), use provided patch or 0 + patch = options[:patch_number] || 0 + end + + version_number = "#{year}.#{week}.#{patch}" + + # Export the version number to the GitHub Actions environment + export_to_github_env('VERSION_NUMBER', version_number) + + increment_version_number( + version_number: version_number, + xcodeproj: PROJECT, + ) + end + + desc 'Runs build action' + desc '#### Example:' + desc "```\nbundle exec fastlane build build_number:42 --env stg\n```" + desc '#### Options:' + desc ' * **`build_number`**: The build number to use. Defaults to the build number stored on `https://increment.build`.' + lane :build do |options| + codeSigning( + type: 'appstore', + code_signing_identity: 'Apple Distribution', + ) + + increment_build_number( + build_number: options[:build_number] || fetch_and_increment_build_number, + ) + + # Change app icon if needed + allowedAppIconTags = ['STG'] + if allowedAppIconTags.include?(ENV['APP_ICON_TAG']) + update_app_icon(ENV['APP_ICON_TAG']) + end + + build_ios_app( + project: PROJECT, + scheme: ENV['SCHEME'], + output_directory: OUTPUT_DIRECTORY, + output_name: ENV['GYM_OUTPUT_NAME'], + export_method: 'app-store', + derived_data_path: DERIVED_DATA_PATH, + suppress_xcode_output: true, + analyze_build_time: true, + skip_profile_detection: true, + cloned_source_packages_path: SOURCE_PACKAGES_PATH, + skip_package_dependencies_resolution: ENV['SPM_CACHE_RESTORED'] == 'true', # Checking for == `true` as the value can also be `inexact` + disable_package_automatic_updates: true, + ) + + reset_git_repo( + files: [PROJECT, "Template/Assets/Assets.xcassets/"], + force: true, + ) + end + + desc 'Runs tests' + desc '#### Example:' + desc "```\nbundle exec fastlane test --env stg\n```" + lane :test do + run_tests( + project: PROJECT, + scheme: ENV['SCHEME'], + prelaunch_simulator: true, + output_directory: OUTPUT_DIRECTORY, + suppress_xcode_output: true, + derived_data_path: DERIVED_DATA_PATH, + result_bundle: true, + skip_slack: true, + cloned_source_packages_path: SOURCE_PACKAGES_PATH, + skip_package_dependencies_resolution: ENV['SPM_CACHE_RESTORED'] == 'true', # Checking for == `true` as the value can also be `inexact` + disable_package_automatic_updates: true, + xcargs: '-skipMacroValidation', + ) + end + + desc 'Uploads the given file to TestFlight.' + desc '#### Example:' + desc "```\nbundle exec fastlane deploy --env stg\n```" + desc '#### Options:' + desc ' * **`changelog`**: The changelog to be used for this build.' + desc '#### Warning:' + desc 'Adding a changelog makes the build wait for TestFlight processing to update the changelog, which results in 7.5min waiting time.' + lane :deploy do |options| + api_key = app_store_connect_api_key( + key_id: ENV['APP_STORE_CONNECT_API_KEY_KEY_ID'], + issuer_id: ENV['APP_STORE_CONNECT_API_KEY_ISSUER_ID'], + key_content: ENV['APP_STORE_CONNECT_API_KEY_KEY'], + ) + + upload_to_testflight( + api_key: api_key, + app_identifier: ENV['APP_IDENTIFIER'], + ipa: "#{OUTPUT_DIRECTORY}/#{ENV['GYM_OUTPUT_NAME']}", + changelog: ENV['PILOT_CHANGELOG'] || options[:changelog], + skip_waiting_for_build_processing: ENV['PILOT_SKIP_WAITING_FOR_BUILD_PROCESSING'], + distribute_external: ENV['PILOT_DISTRIBUTE_EXTERNAL'], + notify_external_testers: ENV['PILOT_NOTIFY_DISTRIBUTE_EXTERNAL'], + groups: ENV['PILOT_GROUPS'], + reject_build_waiting_for_review: ENV['PILOT_REJECT_BUILD_WAITING_FOR_REVIEW'], + submit_beta_review: ENV['PILOT_SUBMIT_BETA_REVIEW'], + ) + end + + desc 'Uploads metadata to App Store Connect and optionally creates a new version.' + desc '#### Example:' + desc "```\nbundle exec fastlane upload_metadata version_number:2024.52.0 build_number:666 --env prd\n```" + desc '#### Options:' + desc ' * **`version_number`**: The version number to use.' + desc ' * **`build_number`**: The build number to use.' + lane :upload_metadata do |options| + api_key = if is_ci + app_store_connect_api_key( + key_id: ENV['APP_STORE_CONNECT_API_KEY_KEY_ID'], + issuer_id: ENV['APP_STORE_CONNECT_API_KEY_ISSUER_ID'], + key_content: ENV['APP_STORE_CONNECT_API_KEY_KEY'], + ) + else + app_store_connect_api_key( + key_id: "CHANGE_ME_ASC_KEY_ID", # <- CHANGE + issuer_id: "CHANGE_ME_ASC_ISSUER_ID", # <- CHANGE + key_filepath: File.expand_path('~/Developer/AuthKey_CHANGE_ME_ASC_KEY_ID.p8'), # <- CHANGE Put the file into your Developer folder. + ) + end + + version_number = options[:version_number] || get_version_number(xcodeproj: PROJECT, target: ENV['SCHEME']) + + upload_to_app_store( + api_key: api_key, + app_identifier: ENV['APP_IDENTIFIER'], + app_version: version_number, + build_number: options[:build_number], + platform: 'ios', + skip_binary_upload: true, + skip_screenshots: true, + skip_metadata: false, + force: true, + submit_for_review: false, + reject_if_possible: true, + automatic_release: false, + copyright: "#{Date.today.year} CHANGE_ME_LEGAL_ENTITY", # <- CHANGE + precheck_include_in_app_purchases: false, + ) + end +end diff --git a/fastlane/Matchfile b/fastlane/Matchfile new file mode 100644 index 0000000..e72125e --- /dev/null +++ b/fastlane/Matchfile @@ -0,0 +1,15 @@ +git_url("git@github.com:hoppsen/certificates.git") +git_branch('main') + +team_id('XXXXXXXXXX') # <- CHANGE + +storage_mode("git") + +app_identifier(["com.hoppsen.template"]) # <- CHANGE + +username('apple@example.com') # Not needed, as we are relying on the API key. + +# For all available options run `fastlane match --help` +# Remove the # in the beginning of the line to enable the other options + +# The docs are available on https://docs.fastlane.tools/actions/match diff --git a/fastlane/Pluginfile b/fastlane/Pluginfile new file mode 100644 index 0000000..87578f8 --- /dev/null +++ b/fastlane/Pluginfile @@ -0,0 +1,5 @@ +# Autogenerated by fastlane +# +# Ensure this file is checked in to source control! + +gem 'fastlane-plugin-versioning' diff --git a/fastlane/README.md b/fastlane/README.md index d1e7e70..a3000b7 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -33,25 +33,51 @@ bundle exec fastlane setup * **`ssh`**: Set to `true`, if you are currently not using SSH. Defaults to `false` + * **`renew`**: Set to `true`, if you want to regenerate (!) all certificates and provisioning profiles with sync_code_signing (alias match). Defaults to `false` + * **`clean`**: Set to `true`, if you want to clean all provisioning profiles. Defaults to `false` -### ios rename +### ios codeSigning ```sh -[bundle exec] fastlane ios rename +[bundle exec] fastlane ios codeSigning ``` -Renames the project including all occurences of `Template` to a new name of your choice. +Run this to install the code signing certificates #### Example: ``` -bundle exec fastlane rename new_name:Tahdith +bundle exec fastlane codeSigning type:appstore ``` #### Options: - * **`new_name`**: New project name of your choice. + * **`type`**: Specify the type you want to syncronize the certificates for. Defaults to 'appstore' + + * **`renew`**: Set to `true`, if you want to regenerate (!) all certificates and provisioning profiles with sync_code_signing (alias match). Defaults to `false` + + * **`code_signing_identity`**: Specify the code signing identity you want to use. Defaults to `Apple Distribution` + +### ios registerDevice + +```sh +[bundle exec] fastlane ios registerDevice +``` + +Register a new device to Hoppsen's App Store Connect account. You might want to renew the provisioning profiles by using the :setup or :codeSigning lane. + +#### Example: + +``` +bundle exec fastlane registerDevice name:"Firstname Lastname - iPhone 15 Pro"" udid: +``` + +#### Options: + + * **`name`**: The name of the device: " - " + + * **`udid`**: The UDID of the device you want to add ### ios simulator @@ -71,6 +97,24 @@ bundle exec fastlane simulator * **`devices`**: Array of simulators to update. +### ios rename + +```sh +[bundle exec] fastlane ios rename +``` + +Renames the project including all occurrences of `Template` to a new name of your choice. + +#### Example: + +``` +bundle exec fastlane rename new_name:Tahdith +``` + +#### Options: + + * **`new_name`**: New project name of your choice. + ### ios lint ```sh @@ -117,6 +161,98 @@ Generates the Asset enum out of Assets.xcassets. For IMAGES only! bundle exec fastlane assets ``` +### ios updateVersion + +```sh +[bundle exec] fastlane ios updateVersion +``` + +Updates version number + +#### Example: + +``` +bundle exec fastlane updateVersion +``` + +#### Options: + + * **`patch_number`**: Patch number to be appended to the version number in the following format: .. Defaults to `0`. Increments the patch number if the current week is the same as the latest production version. + +### ios build + +```sh +[bundle exec] fastlane ios build +``` + +Runs build action + +#### Example: + +``` +bundle exec fastlane build build_number:42 --env stg +``` + +#### Options: + + * **`build_number`**: The build number to use. Defaults to the build number stored on `https://increment.build`. + +### ios test + +```sh +[bundle exec] fastlane ios test +``` + +Runs tests + +#### Example: + +``` +bundle exec fastlane test --env stg +``` + +### ios deploy + +```sh +[bundle exec] fastlane ios deploy +``` + +Uploads the given file to TestFlight. + +#### Example: + +``` +bundle exec fastlane deploy --env stg +``` + +#### Options: + + * **`changelog`**: The changelog to be used for this build. + +#### Warning: + +Adding a changelog makes the build wait for TestFlight processing to update the changelog, which results in 7.5min waiting time. + +### ios upload_metadata + +```sh +[bundle exec] fastlane ios upload_metadata +``` + +Uploads metadata to App Store Connect and optionally creates a new version. + +#### Example: + +``` +bundle exec fastlane upload_metadata version_number:2024.52.0 build_number:666 --env prd +``` + +#### Options: + + * **`version_number`**: The version number to use. + + * **`build_number`**: The build number to use. + ---- This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. diff --git a/fastlane/Setupfile b/fastlane/Setupfile index f936d41..5d9f717 100644 --- a/fastlane/Setupfile +++ b/fastlane/Setupfile @@ -30,7 +30,7 @@ def setup_ssh "AddKeysToAgent yes\n\t" \ "IdentityFile #{filename}\n" - sh("ssh-keygen -t ecdsa -f #{filename} -C #{ENV['USER']}@hoppsen.com") + sh("ssh-keygen -t ed25519 -f #{filename} -C #{ENV['USER']}@hoppsen.com") sh("ssh-add -K #{filename}") sh("ssh-keyscan -H github.com >> #{sshKnownHosts}", log: false) sh("echo \"#{config}\" >> #{sshConfig}", log: false) @@ -76,7 +76,9 @@ def setup_brew UI.user_error!('You need to install homebrew first! Please follow the install instructions on the just opened webpage.') end - sh('brew install python') # macOS 12.3 doesn't come with python pre-installed :/ + sh('brew install python') # Used by scripts/credits.py + + sh('brew install peripheryapp/periphery/periphery') # Dead-code scanning sh('brew install mint') sh('mint bootstrap --mintfile ../Mintfile') @@ -95,6 +97,7 @@ platform :ios do desc "```\nbundle exec fastlane setup\n```" desc '#### Options:' desc ' * **`ssh`**: Set to `true`, if you are currently not using SSH. Defaults to `false`' + desc ' * **`renew`**: Set to `true`, if you want to regenerate (!) all certificates and provisioning profiles with sync_code_signing (alias match). Defaults to `false`' desc ' * **`clean`**: Set to `true`, if you want to clean all provisioning profiles. Defaults to `false`' lane :setup do |options| ensure_project_was_renamed @@ -107,8 +110,88 @@ platform :ios do setup_brew + update_code_signing_settings( + use_automatic_signing: false, + path: PROJECT, + ) + clean = options[:clean] || false clean_provisioning_profiles(clean) + + renew = options[:renew] || false + sync_code_signing( + type: 'development', + force: renew, + readonly: !renew, + ) + end + + desc 'Run this to install the code signing certificates' + desc '#### Example:' + desc "```\nbundle exec fastlane codeSigning type:appstore\n```" + desc '#### Options:' + desc " * **`type`**: Specify the type you want to syncronize the certificates for. Defaults to 'appstore'" + desc ' * **`renew`**: Set to `true`, if you want to regenerate (!) all certificates and provisioning profiles with sync_code_signing (alias match). Defaults to `false`' + desc ' * **`code_signing_identity`**: Specify the code signing identity you want to use. Defaults to `Apple Distribution`' + lane :codeSigning do |options| + ensure_project_was_renamed + + app_store_connect_api_key( + key_id: "CHANGE_ME_ASC_KEY_ID", # <- CHANGE + issuer_id: "CHANGE_ME_ASC_ISSUER_ID", # <- CHANGE + key_filepath: File.expand_path('~/Developer/AuthKey_CHANGE_ME_ASC_KEY_ID.p8'), # <- CHANGE Put the file into your Developer folder. + ) unless is_ci + + type = options[:type] || 'appstore' + renew = options[:renew] || false + code_signing_identity = options[:code_signing_identity] || 'Apple Distribution' + + # Installs the specified certificates and profiles from the storage + sync_code_signing( + type: type, + force: renew, + readonly: !renew, + clone_branch_directly: true, + ) + + # sync_code_signing is not changing the project itself, therefore we need to set the profiles and certificates for each target manually + update_project_provisioning( + xcodeproj: PROJECT, + profile: ENV["sigh_#{ENV['APP_IDENTIFIER']}_#{type}_profile-path"], + target_filter: '^Template(Staging)?$', + code_signing_identity: code_signing_identity, # Only updates CODE_SIGN_IDENTITY of the target not the whole project + ) + # update_project_provisioning( + # xcodeproj: PROJECT, + # profile: ENV["sigh_#{ENV['APP_IDENTIFIER']}.WidgetExtension_#{type}_profile-path"], + # target_filter: '^WidgetExtensionExtension$', + # code_signing_identity: code_signing_identity, + # ) + end + + desc "Register a new device to Hoppsen's App Store Connect account. You might want to renew the provisioning profiles by using the :setup or :codeSigning lane." + desc '#### Example:' + desc "```\nbundle exec fastlane registerDevice name:\"Firstname Lastname - iPhone 15 Pro\"\" udid:\n```" + desc '#### Options:' + desc " * **`name`**: The name of the device: \" - \"" + desc ' * **`udid`**: The UDID of the device you want to add' + lane :registerDevice do |options| + app_store_connect_api_key( + key_id: "CHANGE_ME_ASC_KEY_ID", # <- CHANGE + issuer_id: "CHANGE_ME_ASC_ISSUER_ID", # <- CHANGE + key_filepath: File.expand_path('~/Developer/AuthKey_CHANGE_ME_ASC_KEY_ID.p8'), # <- CHANGE Put the file into your Developer folder. + ) unless is_ci + + register_devices( + devices: { + "Hoppsen - #{options[:name]}" => options[:udid], + }, + ) + + setup( + renew: true, + clean: true, + ) if HighLine.agree('Are you done registering new devices? (y|n)') end desc 'Updates the status bar of all booted simulators.' @@ -117,13 +200,13 @@ platform :ios do desc '#### Options:' desc ' * **`devices`**: Array of simulators to update.' lane :simulator do |options| - devices = options[:devices] || ['iPhone 13 Pro Max', 'iPhone 8 Plus', 'iPad Pro (12.9-inch) (5th generation)', 'iPad Pro (12.9-inch) (2nd generation)', 'booted'] + devices = options[:devices] || ['iPhone 15 Pro Max', 'iPhone 8 Plus', 'booted'] for device in devices sh("xcrun simctl status_bar \"#{device}\" override --time 9:41 --dataNetwork wifi --wifiMode active --wifiBars 3 --operatorName 'Hoppsen' --cellularMode active --cellularBars 4 --batteryState charged --batteryLevel 100") end end - desc 'Renames the project including all occurences of `Template` to a new name of your choice.' + desc 'Renames the project including all occurrences of `Template` to a new name of your choice.' desc '#### Example:' desc "```\nbundle exec fastlane rename new_name:Tahdith\n```" desc '#### Options:' @@ -132,6 +215,12 @@ platform :ios do new_name = options[:new_name] rename_project("Template", new_name) + update_app_identifier( + xcodeproj: "#{new_name}.xcodeproj", + plist_path: "#{new_name}/Info.plist", + app_identifier: "com.hoppsen.#{new_name.downcase}" + ) + update_marketing_version(new_name) end end diff --git a/fastlane/actions/get_app_store_version_number.rb b/fastlane/actions/get_app_store_version_number.rb new file mode 100644 index 0000000..5e9189d --- /dev/null +++ b/fastlane/actions/get_app_store_version_number.rb @@ -0,0 +1,110 @@ +module Fastlane + module Actions + class GetAppStoreVersionNumberAction < Action + + require 'json' + require 'securerandom' + + def self.run(params) + if params[:bundle_id] + bundle_id = params[:bundle_id] + else + if Helper.test? + plist = "/tmp/fastlane/tests/fastlane/plist/Info.plist" + else + plist = GetInfoPlistPathAction.run(params) + end + bundle_id = GetInfoPlistValueAction.run(path: plist, key: 'CFBundleIdentifier') # TODO: add same kind of flag to support build setting variables + end + + random = Helper.test? ? "123" : SecureRandom.uuid + + if params[:country] + uri = URI("https://itunes.apple.com/lookup?bundleId=#{bundle_id}&country=#{params[:country]}&rand=#{random}") + else + uri = URI("https://itunes.apple.com/lookup?bundleId=#{bundle_id}&rand=#{random}") + end + Net::HTTP.get(uri) + + response = Net::HTTP.get_response(uri) + case response + when Net::HTTPSuccess + response_body = JSON.parse(response.body) + when Net::HTTPRedirection + UI.crash!("iTunes Search API resolved with status code 302, but no redirect url has been received") unless response['location'] + UI.important("iTunes Search API resolved with status code 302, redirecting to new url") + + new_url = response['location'] + response = Net::HTTP.get_response(URI(new_url)) + UI.crash!("Unexpected status code from iTunes Search API") unless response.kind_of?(Net::HTTPSuccess) + + response_body = JSON.parse(response.body) + else + UI.crash!("Unexpected status code from iTunes Search API") + end + + UI.user_error!("Cannot find app with #{bundle_id} bundle ID in the App Store") if response_body["resultCount"] == 0 + + response_body["results"][0]["version"] + end + + ##################################################### + # @!group Documentation + ##################################################### + + def self.description + "Get the version number of your app in the App Store" + end + + def self.available_options + [ + FastlaneCore::ConfigItem.new(key: :bundle_id, + env_name: "FL_APPSTORE_VERSION_NUMBER_BUNDLE_ID", + description: "Bundle ID of the application", + optional: true, + conflicting_options: %i[xcodeproj target scheme build_configuration_name], + is_string: true), + FastlaneCore::ConfigItem.new(key: :xcodeproj, + env_name: "FL_VERSION_NUMBER_PROJECT", + description: "optional, you must specify the path to your main Xcode project if it is not in the project root directory", + optional: true, + conflicting_options: [:bundle_id], + verify_block: proc do |value| + UI.user_error!("Please pass the path to the project, not the workspace") if value.end_with? ".xcworkspace" + UI.user_error!("Could not find Xcode project at path '#{File.expand_path(value)}'") if !File.exist?(value) and !Helper.is_test? + end), + FastlaneCore::ConfigItem.new(key: :target, + env_name: "FL_VERSION_NUMBER_TARGET", + optional: true, + conflicting_options: %i[bundle_id scheme], + description: "Specify a specific target if you have multiple per project, optional"), + FastlaneCore::ConfigItem.new(key: :scheme, + env_name: "FL_VERSION_NUMBER_SCHEME", + optional: true, + conflicting_options: %i[bundle_id target], + description: "Specify a specific scheme if you have multiple per project, optional"), + FastlaneCore::ConfigItem.new(key: :build_configuration_name, + optional: true, + conflicting_options: [:bundle_id], + description: "Specify a specific build configuration if you have different Info.plist build settings for each configuration"), + FastlaneCore::ConfigItem.new(key: :country, + optional: true, + description: "Pass an optional country code, if your app's availability is limited to specific countries", + is_string: true), + FastlaneCore::ConfigItem.new(key: :skip_package_dependencies_resolution, + description: "Skips resolution of Swift Package Manager dependencies", + type: Boolean, + default_value: false) + ] + end + + def self.authors + ["SiarheiFedartsou", "jdouglas-nz", "raymondjacobson"] + end + + def self.is_supported?(platform) + %i[ios mac].include? platform + end + end + end +end