@moifort/stack 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +26 -2
- package/bin/stack.ts +109 -0
- package/hooks/shutdown-simulators-after-push.sh +19 -0
- package/package.json +40 -4
- package/routines/daily-health.md +78 -0
- package/routines/deprecations.md +23 -0
- package/routines/sentry-autofix.md +76 -0
- package/routines/stack-upgrade.md +45 -0
- package/rules/00-collaboration.md +81 -0
- package/rules/01-workflow.md +81 -0
- package/rules/02-commits.md +71 -0
- package/rules/03-code-review.md +25 -0
- package/rules/04-push.md +75 -0
- package/rules/05-release.md +40 -0
- package/rules/06-changelog.md +48 -0
- package/rules/07-appstore.md +77 -0
- package/rules/08-copy.md +52 -0
- package/rules/09-backend.md +132 -0
- package/rules/10-ios.md +206 -0
- package/rules/11-ops.md +114 -0
- package/rules/12-ci.md +32 -0
- package/rules/13-ai.md +71 -0
- package/rules/14-infra.md +90 -0
- package/rules/15-tooling.md +81 -0
- package/rules/16-routines.md +69 -0
- package/rules/17-landing.md +76 -0
- package/skills/appstore/SKILL.md +53 -0
- package/skills/domain/SKILL.md +136 -0
- package/skills/graphql/SKILL.md +83 -0
- package/skills/ios-feature/SKILL.md +94 -0
- package/skills/stack-install/SKILL.md +35 -0
- package/skills/stack-upgrade/SKILL.md +27 -0
- package/src/catalogue.ts +33 -0
- package/src/config.ts +20 -0
- package/src/init.ts +63 -0
- package/src/migrations.ts +72 -0
- package/src/migrations.unit.test.ts +44 -0
- package/src/package.ts +8 -0
- package/src/render.ts +13 -0
- package/src/render.unit.test.ts +17 -0
- package/src/sync.int.test.ts +103 -0
- package/src/sync.ts +161 -0
- package/src/templates.ts +121 -0
- package/src/validate-content.ts +65 -0
- package/templates/backend/.env.example +10 -0
- package/templates/backend/.github/workflows/deploy.yml +151 -0
- package/templates/backend/.github/workflows/schema.yml +59 -0
- package/templates/backend/.github/workflows/test-feat.yml +40 -0
- package/templates/backend/.github/workflows/test-int.yml +40 -0
- package/templates/backend/.github/workflows/test-unit.yml +64 -0
- package/templates/backend/docs/api-evolution.md +67 -0
- package/templates/backend/firebase.json +21 -0
- package/templates/backend/firestore.indexes.json +4 -0
- package/templates/backend/firestore.rules +11 -0
- package/templates/backend/infra/auth.tf +54 -0
- package/templates/backend/infra/backend.tf +6 -0
- package/templates/backend/infra/cicd.tf +63 -0
- package/templates/backend/infra/firebase.tf +26 -0
- package/templates/backend/infra/firestore.tf +53 -0
- package/templates/backend/infra/function.tf +171 -0
- package/templates/backend/infra/main.tf +26 -0
- package/templates/backend/infra/observer.tf +18 -0
- package/templates/backend/infra/outputs.tf +30 -0
- package/templates/backend/infra/project.tf +64 -0
- package/templates/backend/infra/scheduler.tf +28 -0
- package/templates/backend/infra/secrets.tf +44 -0
- package/templates/backend/infra/state.tf +13 -0
- package/templates/backend/infra/terraform.tfvars.example +16 -0
- package/templates/backend/infra/variables.tf +104 -0
- package/templates/backend/infra/versions.tf +30 -0
- package/templates/backend/nitro.config.ts +40 -0
- package/templates/backend/package.json +51 -0
- package/templates/backend/scripts/deprecations/inspector-rule.ts +29 -0
- package/templates/backend/scripts/deprecations/status.ts +50 -0
- package/templates/backend/scripts/deprecations/status.unit.test.ts +57 -0
- package/templates/backend/scripts/deprecations/usage.ts +79 -0
- package/templates/backend/scripts/deprecations/usage.unit.test.ts +64 -0
- package/templates/backend/scripts/deprecations.ts +224 -0
- package/templates/backend/scripts/export-graphql-schema.ts +11 -0
- package/templates/backend/scripts/generate-changelog-asset.ts +27 -0
- package/templates/backend/scripts/install-terraform.sh +61 -0
- package/templates/backend/scripts/tf +11 -0
- package/templates/backend/server/domain/changelog/business-rules.ts +60 -0
- package/templates/backend/server/domain/changelog/business-rules.unit.test.ts +88 -0
- package/templates/backend/server/domain/changelog/infrastructure/graphql/queries.ts +17 -0
- package/templates/backend/server/domain/changelog/infrastructure/graphql/types.ts +23 -0
- package/templates/backend/server/domain/changelog/primitives.ts +19 -0
- package/templates/backend/server/domain/changelog/query.ts +13 -0
- package/templates/backend/server/domain/changelog/query.unit.test.ts +19 -0
- package/templates/backend/server/domain/changelog/types.ts +9 -0
- package/templates/backend/server/domain/shared/graphql/builder.ts +52 -0
- package/templates/backend/server/domain/shared/graphql/errors.ts +19 -0
- package/templates/backend/server/domain/shared/graphql/scalars.ts +51 -0
- package/templates/backend/server/domain/shared/graphql/schema.ts +21 -0
- package/templates/backend/server/domain/shared/language.ts +26 -0
- package/templates/backend/server/domain/shared/page.ts +25 -0
- package/templates/backend/server/domain/shared/page.unit.test.ts +51 -0
- package/templates/backend/server/domain/shared/primitives.ts +50 -0
- package/templates/backend/server/domain/shared/primitives.unit.test.ts +25 -0
- package/templates/backend/server/domain/shared/types.ts +14 -0
- package/templates/backend/server/domain/user/command.ts +16 -0
- package/templates/backend/server/domain/user/infrastructure/graphql/inputs.ts +12 -0
- package/templates/backend/server/domain/user/infrastructure/graphql/mutations.ts +40 -0
- package/templates/backend/server/domain/user/infrastructure/graphql/queries.ts +13 -0
- package/templates/backend/server/domain/user/infrastructure/graphql/types.ts +35 -0
- package/templates/backend/server/domain/user/infrastructure/repository.ts +59 -0
- package/templates/backend/server/domain/user/mutations.feat.test.ts +42 -0
- package/templates/backend/server/domain/user/query.int.test.ts +49 -0
- package/templates/backend/server/domain/user/query.ts +34 -0
- package/templates/backend/server/domain/user/types.ts +20 -0
- package/templates/backend/server/domain/user/use-case.ts +18 -0
- package/templates/backend/server/middleware/auth.ts +54 -0
- package/templates/backend/server/plugins/00-sentry.ts +74 -0
- package/templates/backend/server/plugins/01-request-log.ts +28 -0
- package/templates/backend/server/plugins/02-graphql.ts +69 -0
- package/templates/backend/server/plugins/03-boot-time.ts +21 -0
- package/templates/backend/server/routes/admin/migrate.post.ts +9 -0
- package/templates/backend/server/routes/app-config.get.ts +15 -0
- package/templates/backend/server/routes/graphql.ts +52 -0
- package/templates/backend/server/system/app-support.ts +17 -0
- package/templates/backend/server/system/config/index.ts +18 -0
- package/templates/backend/server/system/config/primitives.ts +20 -0
- package/templates/backend/server/system/config/primitives.unit.test.ts +13 -0
- package/templates/backend/server/system/config/types.ts +8 -0
- package/templates/backend/server/system/firebase.ts +19 -0
- package/templates/backend/server/system/identity.ts +18 -0
- package/templates/backend/server/system/logger.ts +119 -0
- package/templates/backend/server/system/logger.unit.test.ts +109 -0
- package/templates/backend/server/system/migration/migrations/index.ts +6 -0
- package/templates/backend/server/system/migration/primitives.ts +18 -0
- package/templates/backend/server/system/migration/runner.ts +52 -0
- package/templates/backend/server/system/migration/types.ts +16 -0
- package/templates/backend/server/system/read-meter.ts +100 -0
- package/templates/backend/server/system/read-meter.unit.test.ts +97 -0
- package/templates/backend/server/system/request-cache.ts +139 -0
- package/templates/backend/server/system/request-cache.unit.test.ts +166 -0
- package/templates/backend/server/system/secret-box.ts +49 -0
- package/templates/backend/server/system/secret-box.unit.test.ts +46 -0
- package/templates/backend/server/test/bdd.ts +12 -0
- package/templates/backend/server/test/fake-firestore.ts +483 -0
- package/templates/backend/server/utils/apollo.ts +13 -0
- package/templates/backend/server/utils/fetch.ts +5 -0
- package/templates/backend/server/utils/firestore.ts +91 -0
- package/templates/backend/server/utils/input.ts +36 -0
- package/templates/backend/server/utils/input.unit.test.ts +43 -0
- package/templates/backend/server/utils/slug.ts +21 -0
- package/templates/backend/tsconfig.json +16 -0
- package/templates/base/.github/workflows/renovate.yml +17 -0
- package/templates/base/CHANGELOG.fr.md +3 -0
- package/templates/base/CHANGELOG.md +7 -0
- package/templates/base/CLAUDE.md +34 -0
- package/templates/base/README.md +12 -0
- package/templates/base/biome.json +47 -0
- package/templates/base/bunfig.toml +2 -0
- package/templates/base/docs/adr/0001-built-on-the-polyforms-stack.md +21 -0
- package/templates/base/docs/data-recovery.md +116 -0
- package/templates/base/docs/ideas.md +4 -0
- package/templates/base/project-context.md +43 -0
- package/templates/base/renovate.json +37 -0
- package/templates/ios/.github/workflows/build-ios.yml +168 -0
- package/templates/ios/.github/workflows/ios-upload.yml +308 -0
- package/templates/ios/.github/workflows/release-ios.yml +178 -0
- package/templates/ios/.github/workflows/testflight-ios.yml +57 -0
- package/templates/ios/infra/Gemfile +5 -0
- package/templates/ios/infra/fastlane/Appfile +1 -0
- package/templates/ios/infra/fastlane/Fastfile +54 -0
- package/templates/ios/infra/fastlane/metadata/copyright.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/en-US/description.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/en-US/keywords.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/en-US/name.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/en-US/privacy_url.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/en-US/promotional_text.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/en-US/subtitle.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/en-US/support_url.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/description.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/keywords.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/name.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/privacy_url.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/promotional_text.txt +0 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/subtitle.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/fr-FR/support_url.txt +1 -0
- package/templates/ios/infra/fastlane/metadata/primary_category.txt +1 -0
- package/templates/ios/infra/ios.tf +28 -0
- package/templates/ios/ios/ExportOptions.plist +26 -0
- package/templates/ios/ios/apollo-codegen-config.json +16 -0
- package/templates/ios/ios/project.yml +94 -0
- package/templates/ios/ios/{{APP_NAME}}/App.swift +46 -0
- package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/AppIcon.appiconset/AppIcon.png +0 -0
- package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
- package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/Contents.json +6 -0
- package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/LaunchBackground.colorset/Contents.json +38 -0
- package/templates/ios/ios/{{APP_NAME}}/ContentView.swift +17 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Auth/AppleNonce.swift +33 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Auth/AuthRoot.swift +74 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Auth/AuthSession.swift +86 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Auth/LoginScreen.swift +83 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Home/HomeScreen.swift +21 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Home/components/pages/HomePage.swift +40 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/AccountEnvironment.swift +7 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/GraphQL/Onboarding.graphql +16 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingAPI.swift +28 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingClient.swift +26 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingGate.swift +50 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingScreen.swift +69 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/components/pages/FirstNamePage.swift +66 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/components/pages/WelcomePage.swift +37 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ChangelogScreen.swift +25 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ChangelogViewModel.swift +27 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/GraphQL/Settings.graphql +11 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ProfileSettingsScreen.swift +30 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ProfileSettingsViewModel.swift +51 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/SettingsAPI.swift +37 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/SettingsClient.swift +40 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/SettingsHomeScreen.swift +39 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/organisms/DeleteAccountButton.swift +30 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/organisms/SettingsRow.swift +56 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/organisms/SignOutButton.swift +23 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/ChangelogPage.swift +59 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/FeedbackSheet.swift +118 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/ProfileSettingsPage.swift +90 -0
- package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/SettingsHomePage.swift +70 -0
- package/templates/ios/ios/{{APP_NAME}}/Info.plist +14 -0
- package/templates/ios/ios/{{APP_NAME}}/Localizable.xcstrings +1030 -0
- package/templates/ios/ios/{{APP_NAME}}/PrivacyInfo.xcprivacy +122 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/APIClient.swift +23 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/AcceptLanguageInterceptor.swift +20 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/atoms/AsyncButton.swift +73 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/atoms/AsyncToolbarButton.swift +69 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/atoms/ToolbarIconButton.swift +46 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/molecules/EmptyState.swift +132 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/molecules/LabeledInfoRow.swift +29 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/CaptionLabelStyle.swift +45 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/Copyable.swift +32 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/EdgeToEdgeSeparator.swift +14 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/RowLabelStyle.swift +58 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/CursorPage.swift +26 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/DataChanges.swift +19 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/ErrorPresenter.swift +62 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/ErrorReporting.swift +35 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/FeedbackReporting.swift +42 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/FirebaseTokenInterceptor.swift +49 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/GraphQLClient.swift +54 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/GraphQLHelpers.swift +128 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/GraphQLLoggingInterceptor.swift +28 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/LoadGate.swift +30 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Secrets.swift.example +9 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Services/AppSupportGate.swift +50 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Shell/StartupLoadingView.swift +23 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Shell/UpdateRequiredView.swift +24 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/Showcase.swift +13 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/SnapshotCache.swift +78 -0
- package/templates/ios/ios/{{APP_NAME}}/Shared/UITestEnvironment.swift +70 -0
- package/templates/ios/ios/{{APP_NAME}}/{{APP_NAME}}.entitlements +10 -0
- package/templates/ios/ios/{{APP_NAME}}Tests/Onboarding/OnboardingGateTests.swift +38 -0
- package/templates/ios/ios/{{APP_NAME}}Tests/Settings/SettingsViewModelTests.swift +63 -0
- package/templates/ios/ios/{{APP_NAME}}Tests/Support/TestDoubles.swift +35 -0
- package/templates/ios/package.merge.json +10 -0
- package/templates/ios/scripts/apollo-codegen.ts +31 -0
- package/templates/ios/scripts/check-ios-components.ts +72 -0
- package/templates/ios/scripts/check-strings.ts +103 -0
- package/templates/ios/scripts/install-device.sh +29 -0
- package/templates/ios/scripts/testflight.ts +213 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
enum OnboardingAPI {
|
|
4
|
+
static func launch() async throws -> LaunchState {
|
|
5
|
+
#if DEBUG
|
|
6
|
+
if Showcase.isOn {
|
|
7
|
+
return LaunchState(firstName: Showcase.firstName, onboardingCompleted: true)
|
|
8
|
+
}
|
|
9
|
+
#endif
|
|
10
|
+
let data = try await GraphQLHelpers.fetch(
|
|
11
|
+
GraphQLClient.shared.apollo,
|
|
12
|
+
query: {{APP_NAME}}GraphQL.LaunchQuery()
|
|
13
|
+
)
|
|
14
|
+
return LaunchState(
|
|
15
|
+
firstName: data.me.firstName,
|
|
16
|
+
onboardingCompleted: data.me.onboardingCompleted
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
static func completeOnboarding(firstName: String) async throws {
|
|
21
|
+
let input = {{APP_NAME}}GraphQL.CompleteOnboardingInput(firstName: firstName)
|
|
22
|
+
_ = try await GraphQLHelpers.perform(
|
|
23
|
+
GraphQLClient.shared.apollo,
|
|
24
|
+
mutation: {{APP_NAME}}GraphQL.CompleteOnboardingMutation(input: input),
|
|
25
|
+
changesData: false
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// What a signed-in launch reads, in one request.
|
|
4
|
+
struct LaunchState: Sendable {
|
|
5
|
+
let firstName: String?
|
|
6
|
+
let onboardingCompleted: Bool
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/// What the onboarding asks of the server, as closures: `.live` goes through the
|
|
10
|
+
/// API, `.preview` answers at once, a test builds the one it needs inline.
|
|
11
|
+
struct OnboardingClient: Sendable {
|
|
12
|
+
var launch: @Sendable () async throws -> LaunchState
|
|
13
|
+
var complete: @Sendable (_ firstName: String) async throws -> Void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
extension OnboardingClient {
|
|
17
|
+
static let live = OnboardingClient(
|
|
18
|
+
launch: { try await OnboardingAPI.launch() },
|
|
19
|
+
complete: { try await OnboardingAPI.completeOnboarding(firstName: $0) }
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
static let preview = OnboardingClient(
|
|
23
|
+
launch: { LaunchState(firstName: "Ada", onboardingCompleted: true) },
|
|
24
|
+
complete: { _ in }
|
|
25
|
+
)
|
|
26
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import Observation
|
|
2
|
+
|
|
3
|
+
/// Decides, once a user is signed in, whether to show the onboarding or the app.
|
|
4
|
+
/// Reads `me` at launch; `onboardingCompleted` drives the choice. Lives at
|
|
5
|
+
/// `AuthRoot` scope and is refreshed on sign-in and account switch.
|
|
6
|
+
@MainActor
|
|
7
|
+
@Observable
|
|
8
|
+
final class OnboardingGate {
|
|
9
|
+
enum State: Equatable {
|
|
10
|
+
case loading
|
|
11
|
+
case required
|
|
12
|
+
case ready
|
|
13
|
+
case failed(String)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
private(set) var state: State = .loading
|
|
17
|
+
/// The user's first name, riding the same launch request: the profile settings
|
|
18
|
+
/// draw it from here rather than asking the server again.
|
|
19
|
+
private(set) var firstName: String?
|
|
20
|
+
|
|
21
|
+
private let client: OnboardingClient
|
|
22
|
+
|
|
23
|
+
init(client: OnboardingClient = .live) {
|
|
24
|
+
self.client = client
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func refresh() async {
|
|
28
|
+
state = .loading
|
|
29
|
+
do {
|
|
30
|
+
let launch = try await client.launch()
|
|
31
|
+
firstName = launch.firstName
|
|
32
|
+
state = launch.onboardingCompleted ? .ready : .required
|
|
33
|
+
} catch {
|
|
34
|
+
state = .failed(reportError(error))
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/// Called by the onboarding on success, to enter the app without a re-fetch.
|
|
39
|
+
func markCompleted(firstName: String) {
|
|
40
|
+
self.firstName = firstName
|
|
41
|
+
state = .ready
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// Clears the resolved state on sign-out, so the next account never sees the
|
|
45
|
+
/// previous one's state for a frame.
|
|
46
|
+
func reset() {
|
|
47
|
+
state = .loading
|
|
48
|
+
firstName = nil
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import FirebaseCore
|
|
2
|
+
import SwiftUI
|
|
3
|
+
|
|
4
|
+
/// Shown once, before the app: a welcome that says what the app is for, then the
|
|
5
|
+
/// first name, the one thing it asks.
|
|
6
|
+
struct OnboardingScreen: View {
|
|
7
|
+
/// Handed the first name the user gave, so the app knows it without asking.
|
|
8
|
+
var onCompleted: (String) -> Void
|
|
9
|
+
var client: OnboardingClient = .live
|
|
10
|
+
|
|
11
|
+
@Environment(AuthSession.self) private var session
|
|
12
|
+
@State private var step: Step = .welcome
|
|
13
|
+
@State private var firstName = ""
|
|
14
|
+
@State private var isSaving = false
|
|
15
|
+
@State private var errorMessage: String?
|
|
16
|
+
|
|
17
|
+
private enum Step {
|
|
18
|
+
case welcome
|
|
19
|
+
case firstName
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
var body: some View {
|
|
23
|
+
NavigationStack {
|
|
24
|
+
switch step {
|
|
25
|
+
case .welcome:
|
|
26
|
+
WelcomePage(onNext: { withAnimation { step = .firstName } })
|
|
27
|
+
case .firstName:
|
|
28
|
+
FirstNamePage(
|
|
29
|
+
firstName: $firstName,
|
|
30
|
+
isSaving: isSaving,
|
|
31
|
+
onNext: { Task { await complete() } },
|
|
32
|
+
onBack: { withAnimation { step = .welcome } }
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
.alert(
|
|
37
|
+
"Impossible de terminer",
|
|
38
|
+
isPresented: .init(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })
|
|
39
|
+
) {
|
|
40
|
+
Button("OK", role: .cancel) {}
|
|
41
|
+
} message: {
|
|
42
|
+
Text(errorMessage ?? "")
|
|
43
|
+
}
|
|
44
|
+
.onAppear {
|
|
45
|
+
// Proposed, not imposed: the field stays editable.
|
|
46
|
+
if firstName.isEmpty, let suggested = session.suggestedFirstName {
|
|
47
|
+
firstName = suggested
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private func complete() async {
|
|
53
|
+
let name = firstName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
54
|
+
guard !name.isEmpty else { return }
|
|
55
|
+
isSaving = true
|
|
56
|
+
defer { isSaving = false }
|
|
57
|
+
do {
|
|
58
|
+
try await client.complete(name)
|
|
59
|
+
onCompleted(name)
|
|
60
|
+
} catch {
|
|
61
|
+
errorMessage = reportError(error)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
#Preview {
|
|
67
|
+
if FirebaseApp.app() == nil { FirebaseApp.configure() }
|
|
68
|
+
return OnboardingScreen(onCompleted: { _ in }, client: .preview).environment(AuthSession())
|
|
69
|
+
}
|
package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/components/pages/FirstNamePage.swift
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
struct FirstNamePage: View {
|
|
4
|
+
@Binding var firstName: String
|
|
5
|
+
var isSaving: Bool
|
|
6
|
+
var onNext: () -> Void
|
|
7
|
+
var onBack: () -> Void
|
|
8
|
+
|
|
9
|
+
@FocusState private var focused: Bool
|
|
10
|
+
|
|
11
|
+
private var trimmed: String {
|
|
12
|
+
firstName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
var body: some View {
|
|
16
|
+
VStack(alignment: .leading, spacing: 24) {
|
|
17
|
+
VStack(alignment: .leading, spacing: 8) {
|
|
18
|
+
Text("Comment vous appelez-vous ?")
|
|
19
|
+
.font(.title.bold())
|
|
20
|
+
Text("Votre prénom sert à personnaliser l'application.")
|
|
21
|
+
.font(.subheadline)
|
|
22
|
+
.foregroundStyle(.secondary)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
TextField("Prénom", text: $firstName)
|
|
26
|
+
.textContentType(.givenName)
|
|
27
|
+
.textInputAutocapitalization(.words)
|
|
28
|
+
.submitLabel(.next)
|
|
29
|
+
.focused($focused)
|
|
30
|
+
.onSubmit { if !trimmed.isEmpty { onNext() } }
|
|
31
|
+
.padding()
|
|
32
|
+
.background(Color(.systemGray6))
|
|
33
|
+
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
34
|
+
.accessibilityIdentifier("onboarding-firstname-field")
|
|
35
|
+
|
|
36
|
+
Spacer()
|
|
37
|
+
|
|
38
|
+
Button(action: onNext) {
|
|
39
|
+
ZStack {
|
|
40
|
+
Text("Continuer").opacity(isSaving ? 0 : 1)
|
|
41
|
+
if isSaving { ProgressView() }
|
|
42
|
+
}
|
|
43
|
+
.frame(maxWidth: .infinity)
|
|
44
|
+
}
|
|
45
|
+
.buttonStyle(.borderedProminent)
|
|
46
|
+
.controlSize(.large)
|
|
47
|
+
.disabled(trimmed.isEmpty || isSaving)
|
|
48
|
+
.accessibilityIdentifier("onboarding-firstname-next")
|
|
49
|
+
}
|
|
50
|
+
.padding()
|
|
51
|
+
.navigationTitle("Prénom")
|
|
52
|
+
.navigationBarTitleDisplayMode(.inline)
|
|
53
|
+
.toolbar {
|
|
54
|
+
ToolbarItem(placement: .topBarLeading) {
|
|
55
|
+
ToolbarIconButton(title: "Retour", systemImage: "chevron.left", action: onBack)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
.onAppear { focused = true }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#Preview {
|
|
63
|
+
NavigationStack {
|
|
64
|
+
FirstNamePage(firstName: .constant("Ada"), isSaving: false, onNext: {}, onBack: {})
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
struct WelcomePage: View {
|
|
4
|
+
var onNext: () -> Void
|
|
5
|
+
|
|
6
|
+
var body: some View {
|
|
7
|
+
VStack(spacing: 24) {
|
|
8
|
+
Spacer()
|
|
9
|
+
Image(systemName: "sparkles")
|
|
10
|
+
.font(.system(size: 72))
|
|
11
|
+
.foregroundStyle(.tint)
|
|
12
|
+
VStack(spacing: 12) {
|
|
13
|
+
Text("Bienvenue dans {{APP_NAME}}")
|
|
14
|
+
.font(.largeTitle.bold())
|
|
15
|
+
.multilineTextAlignment(.center)
|
|
16
|
+
Text("{{APP_TAGLINE}}")
|
|
17
|
+
.font(.body)
|
|
18
|
+
.foregroundStyle(.secondary)
|
|
19
|
+
.multilineTextAlignment(.center)
|
|
20
|
+
}
|
|
21
|
+
.padding(.horizontal)
|
|
22
|
+
Spacer()
|
|
23
|
+
Button(action: onNext) {
|
|
24
|
+
Text("Commencer")
|
|
25
|
+
.frame(maxWidth: .infinity)
|
|
26
|
+
}
|
|
27
|
+
.buttonStyle(.borderedProminent)
|
|
28
|
+
.controlSize(.large)
|
|
29
|
+
.accessibilityIdentifier("onboarding-start")
|
|
30
|
+
}
|
|
31
|
+
.padding()
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#Preview {
|
|
36
|
+
WelcomePage(onNext: {})
|
|
37
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
/// The release notes, connected.
|
|
4
|
+
struct ChangelogScreen: View {
|
|
5
|
+
@State private var viewModel: ChangelogViewModel
|
|
6
|
+
|
|
7
|
+
init(client: SettingsClient = .live) {
|
|
8
|
+
_viewModel = State(initialValue: ChangelogViewModel(client: client))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
var body: some View {
|
|
12
|
+
ChangelogPage(
|
|
13
|
+
entries: viewModel.entries,
|
|
14
|
+
isLoading: viewModel.isLoading,
|
|
15
|
+
errorMessage: viewModel.errorMessage
|
|
16
|
+
) {
|
|
17
|
+
await viewModel.load()
|
|
18
|
+
}
|
|
19
|
+
.task { await viewModel.load() }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
#Preview {
|
|
24
|
+
NavigationStack { ChangelogScreen(client: .preview) }
|
|
25
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// The release notes, served by the backend in the user's language.
|
|
4
|
+
@MainActor
|
|
5
|
+
@Observable
|
|
6
|
+
final class ChangelogViewModel {
|
|
7
|
+
private(set) var entries: [ChangelogEntry] = []
|
|
8
|
+
private(set) var isLoading = true
|
|
9
|
+
private(set) var errorMessage: String?
|
|
10
|
+
|
|
11
|
+
private let client: SettingsClient
|
|
12
|
+
|
|
13
|
+
init(client: SettingsClient) {
|
|
14
|
+
self.client = client
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
func load() async {
|
|
18
|
+
isLoading = true
|
|
19
|
+
errorMessage = nil
|
|
20
|
+
do {
|
|
21
|
+
entries = try await client.changelog()
|
|
22
|
+
} catch {
|
|
23
|
+
errorMessage = reportError(error)
|
|
24
|
+
}
|
|
25
|
+
isLoading = false
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
/// The account, connected: who it is from the auth session, and the two ways out
|
|
4
|
+
/// through `ProfileSettingsViewModel`.
|
|
5
|
+
struct ProfileSettingsScreen: View {
|
|
6
|
+
@Environment(AuthSession.self) private var authSession
|
|
7
|
+
/// Read at launch with the routing flags: this screen asks the server nothing.
|
|
8
|
+
@Environment(\.accountFirstName) private var firstName
|
|
9
|
+
@State private var viewModel: ProfileSettingsViewModel
|
|
10
|
+
|
|
11
|
+
init(client: SettingsClient = .live) {
|
|
12
|
+
_viewModel = State(initialValue: ProfileSettingsViewModel(client: client))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
var body: some View {
|
|
16
|
+
ProfileSettingsPage(
|
|
17
|
+
firstName: firstName,
|
|
18
|
+
displayName: authSession.user?.displayName,
|
|
19
|
+
email: authSession.user?.email,
|
|
20
|
+
shortUid: String((authSession.user?.uid ?? "").prefix(8)),
|
|
21
|
+
isDeletingAccount: viewModel.isDeletingAccount,
|
|
22
|
+
errorMessage: viewModel.errorMessage
|
|
23
|
+
) { action in
|
|
24
|
+
switch action {
|
|
25
|
+
case .signOut: await viewModel.signOut(endSession: authSession.signOut)
|
|
26
|
+
case .deleteAccount: await viewModel.deleteAccount(endSession: authSession.signOut)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// The two ways out of the account: signing out, which keeps everything, and
|
|
4
|
+
/// deleting, which keeps nothing. Ending the session is the caller's, handed
|
|
5
|
+
/// in as `endSession`: the ViewModel never holds the auth session itself.
|
|
6
|
+
@MainActor
|
|
7
|
+
@Observable
|
|
8
|
+
final class ProfileSettingsViewModel {
|
|
9
|
+
private(set) var isDeletingAccount = false
|
|
10
|
+
/// What went wrong, said under the buttons.
|
|
11
|
+
private(set) var errorMessage: String?
|
|
12
|
+
|
|
13
|
+
private let client: SettingsClient
|
|
14
|
+
|
|
15
|
+
init(client: SettingsClient) {
|
|
16
|
+
self.client = client
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
func signOut(endSession: () throws -> Void) async {
|
|
20
|
+
errorMessage = nil
|
|
21
|
+
do {
|
|
22
|
+
try endSession()
|
|
23
|
+
} catch {
|
|
24
|
+
errorMessage = reportError(error)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func deleteAccount(endSession: () throws -> Void) async {
|
|
29
|
+
isDeletingAccount = true
|
|
30
|
+
errorMessage = nil
|
|
31
|
+
do {
|
|
32
|
+
try await client.deleteAccount()
|
|
33
|
+
} catch {
|
|
34
|
+
// The server may have finished and only its answer been lost — a
|
|
35
|
+
// timeout, the app sent to the background. If the account is gone,
|
|
36
|
+
// the deletion happened: sign out as if the answer had arrived.
|
|
37
|
+
if await client.accountIsGone() { return }
|
|
38
|
+
errorMessage = reportError(error)
|
|
39
|
+
isDeletingAccount = false
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
// Everything is erased, the Firebase user last: drop the now-invalid
|
|
43
|
+
// session and route back to the login screen.
|
|
44
|
+
do {
|
|
45
|
+
try endSession()
|
|
46
|
+
} catch {
|
|
47
|
+
errorMessage = reportError(error)
|
|
48
|
+
isDeletingAccount = false
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// One released version and what it changed, as the app shows it.
|
|
4
|
+
struct ChangelogEntry: Identifiable, Sendable {
|
|
5
|
+
var id: String { version }
|
|
6
|
+
let version: String
|
|
7
|
+
let date: Date?
|
|
8
|
+
let notes: [String]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
enum SettingsAPI {
|
|
12
|
+
/// The release notes, served in the caller's language by the backend.
|
|
13
|
+
static func changelog() async throws -> [ChangelogEntry] {
|
|
14
|
+
let data = try await GraphQLHelpers.fetch(
|
|
15
|
+
GraphQLClient.shared.apollo,
|
|
16
|
+
query: {{APP_NAME}}GraphQL.ChangelogQuery()
|
|
17
|
+
)
|
|
18
|
+
return data.changelog.map { entry in
|
|
19
|
+
ChangelogEntry(
|
|
20
|
+
version: entry.version,
|
|
21
|
+
date: entry.date.flatMap(GraphQLHelpers.parseISO8601),
|
|
22
|
+
notes: entry.notes
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// Erases the account and everything it owns, then the Firebase identity.
|
|
28
|
+
/// Irreversible, and required by App Review for any app that creates an
|
|
29
|
+
/// account. Does NOT cancel an App Store subscription: Apple owns that.
|
|
30
|
+
static func deleteAccount() async throws {
|
|
31
|
+
_ = try await GraphQLHelpers.perform(
|
|
32
|
+
GraphQLClient.shared.apollo,
|
|
33
|
+
mutation: {{APP_NAME}}GraphQL.DeleteAccountMutation(),
|
|
34
|
+
changesData: false
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Everything the settings screens ask of the server, as closures: `.live` goes
|
|
4
|
+
/// through the API, `.preview` answers at once, a test builds the one it needs.
|
|
5
|
+
struct SettingsClient: Sendable {
|
|
6
|
+
var changelog: @Sendable () async throws -> [ChangelogEntry]
|
|
7
|
+
var deleteAccount: @Sendable () async throws -> Void
|
|
8
|
+
/// After a deletion that seemed to fail: true when the account is gone all the
|
|
9
|
+
/// same, and the session has been dropped.
|
|
10
|
+
var accountIsGone: @Sendable () async -> Bool
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
extension SettingsClient {
|
|
14
|
+
static let live = SettingsClient(
|
|
15
|
+
changelog: { try await SettingsAPI.changelog() },
|
|
16
|
+
deleteAccount: { try await SettingsAPI.deleteAccount() },
|
|
17
|
+
accountIsGone: { await AuthSession.dropSessionIfAccountIsGone() }
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
static let preview = SettingsClient(
|
|
21
|
+
changelog: { ChangelogEntry.previews },
|
|
22
|
+
deleteAccount: {},
|
|
23
|
+
accountIsGone: { false }
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
extension ChangelogEntry {
|
|
28
|
+
static let previews = [
|
|
29
|
+
ChangelogEntry(
|
|
30
|
+
version: "1.1",
|
|
31
|
+
date: .now.addingTimeInterval(-86400 * 3),
|
|
32
|
+
notes: ["Les listes s'ouvrent maintenant plus vite."]
|
|
33
|
+
),
|
|
34
|
+
ChangelogEntry(
|
|
35
|
+
version: "1.0",
|
|
36
|
+
date: .now.addingTimeInterval(-86400 * 30),
|
|
37
|
+
notes: ["Première version."]
|
|
38
|
+
),
|
|
39
|
+
]
|
|
40
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
/// The settings, connected: the account from the auth session, and every sheet
|
|
4
|
+
/// and push of `SettingsHomePage`.
|
|
5
|
+
struct SettingsHomeScreen: View {
|
|
6
|
+
var client: SettingsClient = .live
|
|
7
|
+
|
|
8
|
+
@Environment(AuthSession.self) private var authSession
|
|
9
|
+
@Environment(\.dismiss) private var dismiss
|
|
10
|
+
@State private var feedbackShown = false
|
|
11
|
+
|
|
12
|
+
var body: some View {
|
|
13
|
+
NavigationStack {
|
|
14
|
+
SettingsHomePage(
|
|
15
|
+
profileSubtitle: authSession.user?.displayName ?? authSession.user?.email,
|
|
16
|
+
appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?",
|
|
17
|
+
buildNumber: Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?"
|
|
18
|
+
) { action in
|
|
19
|
+
switch action {
|
|
20
|
+
case .showFeedback: feedbackShown = true
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
.navigationDestination(for: SettingsDestination.self) { destination in
|
|
24
|
+
switch destination {
|
|
25
|
+
case .profile: ProfileSettingsScreen(client: client)
|
|
26
|
+
case .changelog: ChangelogScreen(client: client)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
.sheet(isPresented: $feedbackShown) {
|
|
30
|
+
FeedbackSheet(onSend: sendFeedback)
|
|
31
|
+
}
|
|
32
|
+
.toolbar {
|
|
33
|
+
ToolbarItem(placement: .cancellationAction) {
|
|
34
|
+
ToolbarIconButton(title: "Fermer", systemImage: "xmark", role: .cancel) { dismiss() }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
struct DeleteAccountButton: View {
|
|
4
|
+
var isDeleting: Bool
|
|
5
|
+
let action: () -> Void
|
|
6
|
+
|
|
7
|
+
var body: some View {
|
|
8
|
+
Button(role: .destructive, action: action) {
|
|
9
|
+
if isDeleting {
|
|
10
|
+
ProgressView()
|
|
11
|
+
} else {
|
|
12
|
+
Label {
|
|
13
|
+
Text("Supprimer mon compte")
|
|
14
|
+
} icon: {
|
|
15
|
+
Image(systemName: "trash")
|
|
16
|
+
.foregroundStyle(.red)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
.disabled(isDeleting)
|
|
21
|
+
.accessibilityIdentifier("delete-account")
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#Preview {
|
|
26
|
+
List {
|
|
27
|
+
DeleteAccountButton(isDeleting: false) {}
|
|
28
|
+
DeleteAccountButton(isDeleting: true) {}
|
|
29
|
+
}
|
|
30
|
+
}
|
package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/organisms/SettingsRow.swift
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
/// One entry of the settings list: a tinted badge, a title, and under it a
|
|
4
|
+
/// value read at runtime — an email, a version, a store — which the caller
|
|
5
|
+
/// localizes since it is data, not a key.
|
|
6
|
+
struct SettingsRow: View {
|
|
7
|
+
let icon: String
|
|
8
|
+
let title: LocalizedStringKey
|
|
9
|
+
let subtitle: String?
|
|
10
|
+
let tint: Color
|
|
11
|
+
|
|
12
|
+
/// The badge box scales with Dynamic Type; the glyph is sized to a fixed
|
|
13
|
+
/// fraction of it, so its inset stays constant at every text size.
|
|
14
|
+
@ScaledMetric(relativeTo: .body) private var iconBadgeSize: CGFloat = 28
|
|
15
|
+
private var glyphSize: CGFloat { iconBadgeSize * 0.5 }
|
|
16
|
+
|
|
17
|
+
init(icon: String, title: LocalizedStringKey, subtitle: String? = nil, tint: Color = .accentColor) {
|
|
18
|
+
self.icon = icon
|
|
19
|
+
self.title = title
|
|
20
|
+
self.subtitle = subtitle
|
|
21
|
+
self.tint = tint
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
var body: some View {
|
|
25
|
+
Label {
|
|
26
|
+
VStack(alignment: .leading, spacing: 2) {
|
|
27
|
+
Text(title)
|
|
28
|
+
if let subtitle {
|
|
29
|
+
Text(verbatim: subtitle)
|
|
30
|
+
.font(.footnote)
|
|
31
|
+
.foregroundStyle(.secondary)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
} icon: {
|
|
35
|
+
Image(systemName: icon)
|
|
36
|
+
.font(.system(size: glyphSize))
|
|
37
|
+
.foregroundStyle(.white)
|
|
38
|
+
.frame(width: iconBadgeSize, height: iconBadgeSize)
|
|
39
|
+
.background(
|
|
40
|
+
tint,
|
|
41
|
+
in: RoundedRectangle(cornerRadius: iconBadgeSize * 6 / 28, style: .continuous)
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
// A plain button only answers where something is drawn: stretched and
|
|
45
|
+
// shaped, the whole row is the target, not just its title.
|
|
46
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
47
|
+
.contentShape(.rect)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#Preview {
|
|
52
|
+
List {
|
|
53
|
+
SettingsRow(icon: "person.crop.circle.fill", title: "Profil", subtitle: "ada@example.com", tint: .blue)
|
|
54
|
+
SettingsRow(icon: "doc.text.fill", title: "Version et nouveautés", tint: .indigo)
|
|
55
|
+
}
|
|
56
|
+
}
|
package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/organisms/SignOutButton.swift
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
struct SignOutButton: View {
|
|
4
|
+
let action: () -> Void
|
|
5
|
+
|
|
6
|
+
var body: some View {
|
|
7
|
+
Button(role: .destructive, action: action) {
|
|
8
|
+
Label {
|
|
9
|
+
Text("Se déconnecter")
|
|
10
|
+
} icon: {
|
|
11
|
+
Image(systemName: "rectangle.portrait.and.arrow.right")
|
|
12
|
+
.foregroundStyle(.red)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
.accessibilityIdentifier("sign-out")
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
#Preview {
|
|
20
|
+
List {
|
|
21
|
+
SignOutButton {}
|
|
22
|
+
}
|
|
23
|
+
}
|