@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.
Files changed (263) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +26 -2
  3. package/bin/stack.ts +109 -0
  4. package/hooks/shutdown-simulators-after-push.sh +19 -0
  5. package/package.json +40 -4
  6. package/routines/daily-health.md +78 -0
  7. package/routines/deprecations.md +23 -0
  8. package/routines/sentry-autofix.md +76 -0
  9. package/routines/stack-upgrade.md +45 -0
  10. package/rules/00-collaboration.md +81 -0
  11. package/rules/01-workflow.md +81 -0
  12. package/rules/02-commits.md +71 -0
  13. package/rules/03-code-review.md +25 -0
  14. package/rules/04-push.md +75 -0
  15. package/rules/05-release.md +40 -0
  16. package/rules/06-changelog.md +48 -0
  17. package/rules/07-appstore.md +77 -0
  18. package/rules/08-copy.md +52 -0
  19. package/rules/09-backend.md +132 -0
  20. package/rules/10-ios.md +206 -0
  21. package/rules/11-ops.md +114 -0
  22. package/rules/12-ci.md +32 -0
  23. package/rules/13-ai.md +71 -0
  24. package/rules/14-infra.md +90 -0
  25. package/rules/15-tooling.md +81 -0
  26. package/rules/16-routines.md +69 -0
  27. package/rules/17-landing.md +76 -0
  28. package/skills/appstore/SKILL.md +53 -0
  29. package/skills/domain/SKILL.md +136 -0
  30. package/skills/graphql/SKILL.md +83 -0
  31. package/skills/ios-feature/SKILL.md +94 -0
  32. package/skills/stack-install/SKILL.md +35 -0
  33. package/skills/stack-upgrade/SKILL.md +27 -0
  34. package/src/catalogue.ts +33 -0
  35. package/src/config.ts +20 -0
  36. package/src/init.ts +63 -0
  37. package/src/migrations.ts +72 -0
  38. package/src/migrations.unit.test.ts +44 -0
  39. package/src/package.ts +8 -0
  40. package/src/render.ts +13 -0
  41. package/src/render.unit.test.ts +17 -0
  42. package/src/sync.int.test.ts +103 -0
  43. package/src/sync.ts +161 -0
  44. package/src/templates.ts +121 -0
  45. package/src/validate-content.ts +65 -0
  46. package/templates/backend/.env.example +10 -0
  47. package/templates/backend/.github/workflows/deploy.yml +151 -0
  48. package/templates/backend/.github/workflows/schema.yml +59 -0
  49. package/templates/backend/.github/workflows/test-feat.yml +40 -0
  50. package/templates/backend/.github/workflows/test-int.yml +40 -0
  51. package/templates/backend/.github/workflows/test-unit.yml +64 -0
  52. package/templates/backend/docs/api-evolution.md +67 -0
  53. package/templates/backend/firebase.json +21 -0
  54. package/templates/backend/firestore.indexes.json +4 -0
  55. package/templates/backend/firestore.rules +11 -0
  56. package/templates/backend/infra/auth.tf +54 -0
  57. package/templates/backend/infra/backend.tf +6 -0
  58. package/templates/backend/infra/cicd.tf +63 -0
  59. package/templates/backend/infra/firebase.tf +26 -0
  60. package/templates/backend/infra/firestore.tf +53 -0
  61. package/templates/backend/infra/function.tf +171 -0
  62. package/templates/backend/infra/main.tf +26 -0
  63. package/templates/backend/infra/observer.tf +18 -0
  64. package/templates/backend/infra/outputs.tf +30 -0
  65. package/templates/backend/infra/project.tf +64 -0
  66. package/templates/backend/infra/scheduler.tf +28 -0
  67. package/templates/backend/infra/secrets.tf +44 -0
  68. package/templates/backend/infra/state.tf +13 -0
  69. package/templates/backend/infra/terraform.tfvars.example +16 -0
  70. package/templates/backend/infra/variables.tf +104 -0
  71. package/templates/backend/infra/versions.tf +30 -0
  72. package/templates/backend/nitro.config.ts +40 -0
  73. package/templates/backend/package.json +51 -0
  74. package/templates/backend/scripts/deprecations/inspector-rule.ts +29 -0
  75. package/templates/backend/scripts/deprecations/status.ts +50 -0
  76. package/templates/backend/scripts/deprecations/status.unit.test.ts +57 -0
  77. package/templates/backend/scripts/deprecations/usage.ts +79 -0
  78. package/templates/backend/scripts/deprecations/usage.unit.test.ts +64 -0
  79. package/templates/backend/scripts/deprecations.ts +224 -0
  80. package/templates/backend/scripts/export-graphql-schema.ts +11 -0
  81. package/templates/backend/scripts/generate-changelog-asset.ts +27 -0
  82. package/templates/backend/scripts/install-terraform.sh +61 -0
  83. package/templates/backend/scripts/tf +11 -0
  84. package/templates/backend/server/domain/changelog/business-rules.ts +60 -0
  85. package/templates/backend/server/domain/changelog/business-rules.unit.test.ts +88 -0
  86. package/templates/backend/server/domain/changelog/infrastructure/graphql/queries.ts +17 -0
  87. package/templates/backend/server/domain/changelog/infrastructure/graphql/types.ts +23 -0
  88. package/templates/backend/server/domain/changelog/primitives.ts +19 -0
  89. package/templates/backend/server/domain/changelog/query.ts +13 -0
  90. package/templates/backend/server/domain/changelog/query.unit.test.ts +19 -0
  91. package/templates/backend/server/domain/changelog/types.ts +9 -0
  92. package/templates/backend/server/domain/shared/graphql/builder.ts +52 -0
  93. package/templates/backend/server/domain/shared/graphql/errors.ts +19 -0
  94. package/templates/backend/server/domain/shared/graphql/scalars.ts +51 -0
  95. package/templates/backend/server/domain/shared/graphql/schema.ts +21 -0
  96. package/templates/backend/server/domain/shared/language.ts +26 -0
  97. package/templates/backend/server/domain/shared/page.ts +25 -0
  98. package/templates/backend/server/domain/shared/page.unit.test.ts +51 -0
  99. package/templates/backend/server/domain/shared/primitives.ts +50 -0
  100. package/templates/backend/server/domain/shared/primitives.unit.test.ts +25 -0
  101. package/templates/backend/server/domain/shared/types.ts +14 -0
  102. package/templates/backend/server/domain/user/command.ts +16 -0
  103. package/templates/backend/server/domain/user/infrastructure/graphql/inputs.ts +12 -0
  104. package/templates/backend/server/domain/user/infrastructure/graphql/mutations.ts +40 -0
  105. package/templates/backend/server/domain/user/infrastructure/graphql/queries.ts +13 -0
  106. package/templates/backend/server/domain/user/infrastructure/graphql/types.ts +35 -0
  107. package/templates/backend/server/domain/user/infrastructure/repository.ts +59 -0
  108. package/templates/backend/server/domain/user/mutations.feat.test.ts +42 -0
  109. package/templates/backend/server/domain/user/query.int.test.ts +49 -0
  110. package/templates/backend/server/domain/user/query.ts +34 -0
  111. package/templates/backend/server/domain/user/types.ts +20 -0
  112. package/templates/backend/server/domain/user/use-case.ts +18 -0
  113. package/templates/backend/server/middleware/auth.ts +54 -0
  114. package/templates/backend/server/plugins/00-sentry.ts +74 -0
  115. package/templates/backend/server/plugins/01-request-log.ts +28 -0
  116. package/templates/backend/server/plugins/02-graphql.ts +69 -0
  117. package/templates/backend/server/plugins/03-boot-time.ts +21 -0
  118. package/templates/backend/server/routes/admin/migrate.post.ts +9 -0
  119. package/templates/backend/server/routes/app-config.get.ts +15 -0
  120. package/templates/backend/server/routes/graphql.ts +52 -0
  121. package/templates/backend/server/system/app-support.ts +17 -0
  122. package/templates/backend/server/system/config/index.ts +18 -0
  123. package/templates/backend/server/system/config/primitives.ts +20 -0
  124. package/templates/backend/server/system/config/primitives.unit.test.ts +13 -0
  125. package/templates/backend/server/system/config/types.ts +8 -0
  126. package/templates/backend/server/system/firebase.ts +19 -0
  127. package/templates/backend/server/system/identity.ts +18 -0
  128. package/templates/backend/server/system/logger.ts +119 -0
  129. package/templates/backend/server/system/logger.unit.test.ts +109 -0
  130. package/templates/backend/server/system/migration/migrations/index.ts +6 -0
  131. package/templates/backend/server/system/migration/primitives.ts +18 -0
  132. package/templates/backend/server/system/migration/runner.ts +52 -0
  133. package/templates/backend/server/system/migration/types.ts +16 -0
  134. package/templates/backend/server/system/read-meter.ts +100 -0
  135. package/templates/backend/server/system/read-meter.unit.test.ts +97 -0
  136. package/templates/backend/server/system/request-cache.ts +139 -0
  137. package/templates/backend/server/system/request-cache.unit.test.ts +166 -0
  138. package/templates/backend/server/system/secret-box.ts +49 -0
  139. package/templates/backend/server/system/secret-box.unit.test.ts +46 -0
  140. package/templates/backend/server/test/bdd.ts +12 -0
  141. package/templates/backend/server/test/fake-firestore.ts +483 -0
  142. package/templates/backend/server/utils/apollo.ts +13 -0
  143. package/templates/backend/server/utils/fetch.ts +5 -0
  144. package/templates/backend/server/utils/firestore.ts +91 -0
  145. package/templates/backend/server/utils/input.ts +36 -0
  146. package/templates/backend/server/utils/input.unit.test.ts +43 -0
  147. package/templates/backend/server/utils/slug.ts +21 -0
  148. package/templates/backend/tsconfig.json +16 -0
  149. package/templates/base/.github/workflows/renovate.yml +17 -0
  150. package/templates/base/CHANGELOG.fr.md +3 -0
  151. package/templates/base/CHANGELOG.md +7 -0
  152. package/templates/base/CLAUDE.md +34 -0
  153. package/templates/base/README.md +12 -0
  154. package/templates/base/biome.json +47 -0
  155. package/templates/base/bunfig.toml +2 -0
  156. package/templates/base/docs/adr/0001-built-on-the-polyforms-stack.md +21 -0
  157. package/templates/base/docs/data-recovery.md +116 -0
  158. package/templates/base/docs/ideas.md +4 -0
  159. package/templates/base/project-context.md +43 -0
  160. package/templates/base/renovate.json +37 -0
  161. package/templates/ios/.github/workflows/build-ios.yml +168 -0
  162. package/templates/ios/.github/workflows/ios-upload.yml +308 -0
  163. package/templates/ios/.github/workflows/release-ios.yml +178 -0
  164. package/templates/ios/.github/workflows/testflight-ios.yml +57 -0
  165. package/templates/ios/infra/Gemfile +5 -0
  166. package/templates/ios/infra/fastlane/Appfile +1 -0
  167. package/templates/ios/infra/fastlane/Fastfile +54 -0
  168. package/templates/ios/infra/fastlane/metadata/copyright.txt +1 -0
  169. package/templates/ios/infra/fastlane/metadata/en-US/description.txt +0 -0
  170. package/templates/ios/infra/fastlane/metadata/en-US/keywords.txt +0 -0
  171. package/templates/ios/infra/fastlane/metadata/en-US/name.txt +1 -0
  172. package/templates/ios/infra/fastlane/metadata/en-US/privacy_url.txt +1 -0
  173. package/templates/ios/infra/fastlane/metadata/en-US/promotional_text.txt +0 -0
  174. package/templates/ios/infra/fastlane/metadata/en-US/subtitle.txt +1 -0
  175. package/templates/ios/infra/fastlane/metadata/en-US/support_url.txt +1 -0
  176. package/templates/ios/infra/fastlane/metadata/fr-FR/description.txt +0 -0
  177. package/templates/ios/infra/fastlane/metadata/fr-FR/keywords.txt +0 -0
  178. package/templates/ios/infra/fastlane/metadata/fr-FR/name.txt +1 -0
  179. package/templates/ios/infra/fastlane/metadata/fr-FR/privacy_url.txt +1 -0
  180. package/templates/ios/infra/fastlane/metadata/fr-FR/promotional_text.txt +0 -0
  181. package/templates/ios/infra/fastlane/metadata/fr-FR/subtitle.txt +1 -0
  182. package/templates/ios/infra/fastlane/metadata/fr-FR/support_url.txt +1 -0
  183. package/templates/ios/infra/fastlane/metadata/primary_category.txt +1 -0
  184. package/templates/ios/infra/ios.tf +28 -0
  185. package/templates/ios/ios/ExportOptions.plist +26 -0
  186. package/templates/ios/ios/apollo-codegen-config.json +16 -0
  187. package/templates/ios/ios/project.yml +94 -0
  188. package/templates/ios/ios/{{APP_NAME}}/App.swift +46 -0
  189. package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/AppIcon.appiconset/AppIcon.png +0 -0
  190. package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
  191. package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/Contents.json +6 -0
  192. package/templates/ios/ios/{{APP_NAME}}/Assets.xcassets/LaunchBackground.colorset/Contents.json +38 -0
  193. package/templates/ios/ios/{{APP_NAME}}/ContentView.swift +17 -0
  194. package/templates/ios/ios/{{APP_NAME}}/Features/Auth/AppleNonce.swift +33 -0
  195. package/templates/ios/ios/{{APP_NAME}}/Features/Auth/AuthRoot.swift +74 -0
  196. package/templates/ios/ios/{{APP_NAME}}/Features/Auth/AuthSession.swift +86 -0
  197. package/templates/ios/ios/{{APP_NAME}}/Features/Auth/LoginScreen.swift +83 -0
  198. package/templates/ios/ios/{{APP_NAME}}/Features/Home/HomeScreen.swift +21 -0
  199. package/templates/ios/ios/{{APP_NAME}}/Features/Home/components/pages/HomePage.swift +40 -0
  200. package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/AccountEnvironment.swift +7 -0
  201. package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/GraphQL/Onboarding.graphql +16 -0
  202. package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingAPI.swift +28 -0
  203. package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingClient.swift +26 -0
  204. package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingGate.swift +50 -0
  205. package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/OnboardingScreen.swift +69 -0
  206. package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/components/pages/FirstNamePage.swift +66 -0
  207. package/templates/ios/ios/{{APP_NAME}}/Features/Onboarding/components/pages/WelcomePage.swift +37 -0
  208. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ChangelogScreen.swift +25 -0
  209. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ChangelogViewModel.swift +27 -0
  210. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/GraphQL/Settings.graphql +11 -0
  211. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ProfileSettingsScreen.swift +30 -0
  212. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/ProfileSettingsViewModel.swift +51 -0
  213. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/SettingsAPI.swift +37 -0
  214. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/SettingsClient.swift +40 -0
  215. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/SettingsHomeScreen.swift +39 -0
  216. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/organisms/DeleteAccountButton.swift +30 -0
  217. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/organisms/SettingsRow.swift +56 -0
  218. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/organisms/SignOutButton.swift +23 -0
  219. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/ChangelogPage.swift +59 -0
  220. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/FeedbackSheet.swift +118 -0
  221. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/ProfileSettingsPage.swift +90 -0
  222. package/templates/ios/ios/{{APP_NAME}}/Features/Settings/components/pages/SettingsHomePage.swift +70 -0
  223. package/templates/ios/ios/{{APP_NAME}}/Info.plist +14 -0
  224. package/templates/ios/ios/{{APP_NAME}}/Localizable.xcstrings +1030 -0
  225. package/templates/ios/ios/{{APP_NAME}}/PrivacyInfo.xcprivacy +122 -0
  226. package/templates/ios/ios/{{APP_NAME}}/Shared/APIClient.swift +23 -0
  227. package/templates/ios/ios/{{APP_NAME}}/Shared/AcceptLanguageInterceptor.swift +20 -0
  228. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/atoms/AsyncButton.swift +73 -0
  229. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/atoms/AsyncToolbarButton.swift +69 -0
  230. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/atoms/ToolbarIconButton.swift +46 -0
  231. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/molecules/EmptyState.swift +132 -0
  232. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/molecules/LabeledInfoRow.swift +29 -0
  233. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/CaptionLabelStyle.swift +45 -0
  234. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/Copyable.swift +32 -0
  235. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/EdgeToEdgeSeparator.swift +14 -0
  236. package/templates/ios/ios/{{APP_NAME}}/Shared/Components/styles/RowLabelStyle.swift +58 -0
  237. package/templates/ios/ios/{{APP_NAME}}/Shared/CursorPage.swift +26 -0
  238. package/templates/ios/ios/{{APP_NAME}}/Shared/DataChanges.swift +19 -0
  239. package/templates/ios/ios/{{APP_NAME}}/Shared/ErrorPresenter.swift +62 -0
  240. package/templates/ios/ios/{{APP_NAME}}/Shared/ErrorReporting.swift +35 -0
  241. package/templates/ios/ios/{{APP_NAME}}/Shared/FeedbackReporting.swift +42 -0
  242. package/templates/ios/ios/{{APP_NAME}}/Shared/FirebaseTokenInterceptor.swift +49 -0
  243. package/templates/ios/ios/{{APP_NAME}}/Shared/GraphQLClient.swift +54 -0
  244. package/templates/ios/ios/{{APP_NAME}}/Shared/GraphQLHelpers.swift +128 -0
  245. package/templates/ios/ios/{{APP_NAME}}/Shared/GraphQLLoggingInterceptor.swift +28 -0
  246. package/templates/ios/ios/{{APP_NAME}}/Shared/LoadGate.swift +30 -0
  247. package/templates/ios/ios/{{APP_NAME}}/Shared/Secrets.swift.example +9 -0
  248. package/templates/ios/ios/{{APP_NAME}}/Shared/Services/AppSupportGate.swift +50 -0
  249. package/templates/ios/ios/{{APP_NAME}}/Shared/Shell/StartupLoadingView.swift +23 -0
  250. package/templates/ios/ios/{{APP_NAME}}/Shared/Shell/UpdateRequiredView.swift +24 -0
  251. package/templates/ios/ios/{{APP_NAME}}/Shared/Showcase.swift +13 -0
  252. package/templates/ios/ios/{{APP_NAME}}/Shared/SnapshotCache.swift +78 -0
  253. package/templates/ios/ios/{{APP_NAME}}/Shared/UITestEnvironment.swift +70 -0
  254. package/templates/ios/ios/{{APP_NAME}}/{{APP_NAME}}.entitlements +10 -0
  255. package/templates/ios/ios/{{APP_NAME}}Tests/Onboarding/OnboardingGateTests.swift +38 -0
  256. package/templates/ios/ios/{{APP_NAME}}Tests/Settings/SettingsViewModelTests.swift +63 -0
  257. package/templates/ios/ios/{{APP_NAME}}Tests/Support/TestDoubles.swift +35 -0
  258. package/templates/ios/package.merge.json +10 -0
  259. package/templates/ios/scripts/apollo-codegen.ts +31 -0
  260. package/templates/ios/scripts/check-ios-components.ts +72 -0
  261. package/templates/ios/scripts/check-strings.ts +103 -0
  262. package/templates/ios/scripts/install-device.sh +29 -0
  263. 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
+ }
@@ -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,11 @@
1
+ query Changelog {
2
+ changelog {
3
+ version
4
+ date
5
+ notes
6
+ }
7
+ }
8
+
9
+ mutation DeleteAccount {
10
+ deleteAccount
11
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }