@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,122 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <!-- Nothing here follows the user across other companies' apps or websites:
6
+ no advertising identifier, no ad SDK, no data broker. -->
7
+ <key>NSPrivacyTracking</key>
8
+ <false/>
9
+ <key>NSPrivacyTrackingDomains</key>
10
+ <array/>
11
+ <key>NSPrivacyCollectedDataTypes</key>
12
+ <array>
13
+ <!-- Which screens are reached and which milestones are crossed, read as
14
+ aggregates to know where people give up. -->
15
+ <dict>
16
+ <key>NSPrivacyCollectedDataType</key>
17
+ <string>NSPrivacyCollectedDataTypeProductInteraction</string>
18
+ <key>NSPrivacyCollectedDataTypeLinked</key>
19
+ <true/>
20
+ <key>NSPrivacyCollectedDataTypeTracking</key>
21
+ <false/>
22
+ <key>NSPrivacyCollectedDataTypePurposes</key>
23
+ <array>
24
+ <string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
25
+ </array>
26
+ </dict>
27
+ <!-- The identifier the analytics SDK gives the install, which is what makes
28
+ one session distinguishable from another. -->
29
+ <dict>
30
+ <key>NSPrivacyCollectedDataType</key>
31
+ <string>NSPrivacyCollectedDataTypeDeviceID</string>
32
+ <key>NSPrivacyCollectedDataTypeLinked</key>
33
+ <true/>
34
+ <key>NSPrivacyCollectedDataTypeTracking</key>
35
+ <false/>
36
+ <key>NSPrivacyCollectedDataTypePurposes</key>
37
+ <array>
38
+ <string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
39
+ </array>
40
+ </dict>
41
+ <!-- Crashes and errors, so a bug is fixed before it is reported. -->
42
+ <dict>
43
+ <key>NSPrivacyCollectedDataType</key>
44
+ <string>NSPrivacyCollectedDataTypeCrashData</string>
45
+ <key>NSPrivacyCollectedDataTypeLinked</key>
46
+ <false/>
47
+ <key>NSPrivacyCollectedDataTypeTracking</key>
48
+ <false/>
49
+ <key>NSPrivacyCollectedDataTypePurposes</key>
50
+ <array>
51
+ <string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
52
+ </array>
53
+ </dict>
54
+ <!-- How long a screen took to answer, sampled: the same reports, on the
55
+ requests that did not fail. -->
56
+ <dict>
57
+ <key>NSPrivacyCollectedDataType</key>
58
+ <string>NSPrivacyCollectedDataTypePerformanceData</string>
59
+ <key>NSPrivacyCollectedDataTypeLinked</key>
60
+ <false/>
61
+ <key>NSPrivacyCollectedDataTypeTracking</key>
62
+ <false/>
63
+ <key>NSPrivacyCollectedDataTypePurposes</key>
64
+ <array>
65
+ <string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
66
+ </array>
67
+ </dict>
68
+ <!-- The account itself: a Sign in with Apple identifier and the first name
69
+ the onboarding asks for, without which there is no account. -->
70
+ <dict>
71
+ <key>NSPrivacyCollectedDataType</key>
72
+ <string>NSPrivacyCollectedDataTypeUserID</string>
73
+ <key>NSPrivacyCollectedDataTypeLinked</key>
74
+ <true/>
75
+ <key>NSPrivacyCollectedDataTypeTracking</key>
76
+ <false/>
77
+ <key>NSPrivacyCollectedDataTypePurposes</key>
78
+ <array>
79
+ <string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
80
+ </array>
81
+ </dict>
82
+ <dict>
83
+ <key>NSPrivacyCollectedDataType</key>
84
+ <string>NSPrivacyCollectedDataTypeName</string>
85
+ <key>NSPrivacyCollectedDataTypeLinked</key>
86
+ <true/>
87
+ <key>NSPrivacyCollectedDataTypeTracking</key>
88
+ <false/>
89
+ <key>NSPrivacyCollectedDataTypePurposes</key>
90
+ <array>
91
+ <string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
92
+ </array>
93
+ </dict>
94
+ <!-- The address Sign in with Apple hands back, which is the account. -->
95
+ <dict>
96
+ <key>NSPrivacyCollectedDataType</key>
97
+ <string>NSPrivacyCollectedDataTypeEmailAddress</string>
98
+ <key>NSPrivacyCollectedDataTypeLinked</key>
99
+ <true/>
100
+ <key>NSPrivacyCollectedDataTypeTracking</key>
101
+ <false/>
102
+ <key>NSPrivacyCollectedDataTypePurposes</key>
103
+ <array>
104
+ <string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
105
+ </array>
106
+ </dict>
107
+
108
+ </array>
109
+ <key>NSPrivacyAccessedAPITypes</key>
110
+ <array>
111
+ <!-- UserDefaults: the server URL for a test run. -->
112
+ <dict>
113
+ <key>NSPrivacyAccessedAPIType</key>
114
+ <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
115
+ <key>NSPrivacyAccessedAPITypeReasons</key>
116
+ <array>
117
+ <string>CA92.1</string>
118
+ </array>
119
+ </dict>
120
+ </array>
121
+ </dict>
122
+ </plist>
@@ -0,0 +1,23 @@
1
+ import Foundation
2
+
3
+ /// Resolves the Cloud Function that hosts the GraphQL API. All HTTP transport
4
+ /// happens through GraphQLClient (Apollo iOS).
5
+ ///
6
+ /// `serverURL` in the app's defaults overrides it, for a run against the local
7
+ /// server or the staging project:
8
+ /// `xcrun simctl spawn <udid> defaults write {{BUNDLE_ID}} serverURL http://localhost:3000`.
9
+ struct APIClient: Sendable {
10
+ static let shared = APIClient()
11
+
12
+ private static let serverURLKey = "serverURL"
13
+ // The deployed Cloud Function, written here once the first deploy printed it.
14
+ // A wrong value does not fail loudly: any *.a.run.app host resolves, so the
15
+ // app reaches Google and gets a 404 that reads like a broken API.
16
+ private static let defaultServerURL = "{{SERVER_URL}}"
17
+
18
+ var baseURL: URL {
19
+ let stored = UserDefaults.standard.string(forKey: Self.serverURLKey)
20
+ ?? Self.defaultServerURL
21
+ return URL(string: stored) ?? URL(string: Self.defaultServerURL)!
22
+ }
23
+ }
@@ -0,0 +1,20 @@
1
+ import Apollo
2
+ import Foundation
3
+
4
+ /// Sends the language the app is shown in as `Accept-Language` on every GraphQL
5
+ /// request. The backend uses it to write what it writes in the user's language. The app's language rather
6
+ /// than the device's first one: a user whose iPhone is in German sees the app
7
+ /// in English, and reads its summaries in English too. Runs at the HTTP layer
8
+ /// where the request is a plain `URLRequest`.
9
+ struct AcceptLanguageInterceptor: HTTPInterceptor {
10
+ func intercept(
11
+ request: URLRequest,
12
+ next: NextHTTPInterceptorFunction
13
+ ) async throws -> HTTPResponse {
14
+ var request = request
15
+ if let preferred = Bundle.main.preferredLocalizations.first ?? Locale.preferredLanguages.first {
16
+ request.setValue(preferred, forHTTPHeaderField: "Accept-Language")
17
+ }
18
+ return try await next(request)
19
+ }
20
+ }
@@ -0,0 +1,73 @@
1
+ import SwiftUI
2
+
3
+ /// A button whose action takes time on the network: it shows a spinner in place
4
+ /// of its label from the tap until the call settles, and refuses a second tap
5
+ /// meanwhile. Every call to action that waits on a server goes through this or
6
+ /// its toolbar sibling, so no tap is ever answered by nothing.
7
+ ///
8
+ /// The label keeps its width while the spinner shows, so a row of buttons does
9
+ /// not shuffle when one of them is working.
10
+ struct AsyncButton<Label: View>: View {
11
+ var role: ButtonRole? = nil
12
+ let action: () async -> Void
13
+ @ViewBuilder let label: () -> Label
14
+
15
+ @State private var isInProgress = false
16
+
17
+ init(
18
+ role: ButtonRole? = nil,
19
+ action: @escaping () async -> Void,
20
+ @ViewBuilder label: @escaping () -> Label
21
+ ) {
22
+ self.role = role
23
+ self.action = action
24
+ self.label = label
25
+ }
26
+
27
+ var body: some View {
28
+ Button(role: role) {
29
+ guard !isInProgress else { return }
30
+ isInProgress = true
31
+ Task {
32
+ await action()
33
+ isInProgress = false
34
+ }
35
+ } label: {
36
+ ZStack {
37
+ label().opacity(isInProgress ? 0 : 1)
38
+ if isInProgress {
39
+ ProgressView()
40
+ }
41
+ }
42
+ }
43
+ .disabled(isInProgress)
44
+ }
45
+ }
46
+
47
+ extension AsyncButton where Label == Text {
48
+ init(_ title: LocalizedStringKey, role: ButtonRole? = nil, action: @escaping () async -> Void) {
49
+ self.init(role: role, action: action) { Text(title) }
50
+ }
51
+ }
52
+
53
+ extension AsyncButton where Label == SwiftUI.Label<Text, Image> {
54
+ init(
55
+ _ title: LocalizedStringKey,
56
+ systemImage: String,
57
+ role: ButtonRole? = nil,
58
+ action: @escaping () async -> Void
59
+ ) {
60
+ self.init(role: role, action: action) { SwiftUI.Label(title, systemImage: systemImage) }
61
+ }
62
+ }
63
+
64
+ #Preview {
65
+ VStack(spacing: 16) {
66
+ AsyncButton("Réessayer") { try? await Task.sleep(for: .seconds(2)) }
67
+ AsyncButton("Synchroniser", systemImage: "arrow.triangle.2.circlepath") {
68
+ try? await Task.sleep(for: .seconds(2))
69
+ }
70
+ .buttonStyle(.borderedProminent)
71
+ }
72
+ .padding()
73
+ }
@@ -0,0 +1,69 @@
1
+ import SwiftUI
2
+
3
+ struct AsyncToolbarButton: View {
4
+ let title: LocalizedStringKey
5
+ let systemImage: String
6
+ var role: ButtonRole? = nil
7
+ let action: () async -> Void
8
+
9
+ @State private var isInProgress = false
10
+
11
+ var body: some View {
12
+ Button(role: role) {
13
+ guard !isInProgress else { return }
14
+ isInProgress = true
15
+ Task {
16
+ await action()
17
+ isInProgress = false
18
+ }
19
+ } label: {
20
+ if isInProgress {
21
+ ProgressView()
22
+ } else {
23
+ Label(title, systemImage: systemImage)
24
+ }
25
+ }
26
+ .labelStyle(.iconOnly)
27
+ .disabled(isInProgress)
28
+ }
29
+ }
30
+
31
+ #Preview("Idle") {
32
+ NavigationStack {
33
+ Text("Contenu")
34
+ .toolbar {
35
+ ToolbarItem(placement: .confirmationAction) {
36
+ AsyncToolbarButton(title: "Enregistrer", systemImage: "checkmark") {}
37
+ }
38
+ }
39
+ }
40
+ }
41
+
42
+ #Preview("In progress") {
43
+ NavigationStack {
44
+ Text("Contenu")
45
+ .toolbar {
46
+ ToolbarItem(placement: .confirmationAction) {
47
+ // A long action keeps the spinner on screen in the canvas.
48
+ AsyncToolbarButton(title: "Enregistrer", systemImage: "checkmark") {
49
+ try? await Task.sleep(for: .seconds(60))
50
+ }
51
+ }
52
+ }
53
+ }
54
+ }
55
+
56
+ #Preview("Destructive") {
57
+ NavigationStack {
58
+ Text("Contenu")
59
+ .toolbar {
60
+ ToolbarItem(placement: .destructiveAction) {
61
+ AsyncToolbarButton(
62
+ title: "Supprimer",
63
+ systemImage: "trash",
64
+ role: .destructive
65
+ ) {}
66
+ }
67
+ }
68
+ }
69
+ }
@@ -0,0 +1,46 @@
1
+ import SwiftUI
2
+
3
+ /// Icon-only toolbar button. The `title` is kept purely for accessibility (VoiceOver);
4
+ /// only the SF Symbol is rendered. Used for cancel / close / back and secondary actions.
5
+ struct ToolbarIconButton: View {
6
+ let title: LocalizedStringKey
7
+ let systemImage: String
8
+ var role: ButtonRole? = nil
9
+ let action: () -> Void
10
+
11
+ var body: some View {
12
+ Button(role: role, action: action) {
13
+ Label(title, systemImage: systemImage)
14
+ }
15
+ .labelStyle(.iconOnly)
16
+ }
17
+ }
18
+
19
+ #Preview("Close and confirm") {
20
+ NavigationStack {
21
+ Text("Contenu")
22
+ .toolbar {
23
+ ToolbarItem(placement: .cancellationAction) {
24
+ ToolbarIconButton(title: "Fermer", systemImage: "xmark", role: .cancel) {}
25
+ }
26
+ ToolbarItem(placement: .confirmationAction) {
27
+ ToolbarIconButton(title: "Enregistrer", systemImage: "checkmark") {}
28
+ }
29
+ }
30
+ }
31
+ }
32
+
33
+ #Preview("Destructive") {
34
+ NavigationStack {
35
+ Text("Contenu")
36
+ .toolbar {
37
+ ToolbarItem(placement: .destructiveAction) {
38
+ ToolbarIconButton(
39
+ title: "Supprimer",
40
+ systemImage: "trash",
41
+ role: .destructive
42
+ ) {}
43
+ }
44
+ }
45
+ }
46
+ }
@@ -0,0 +1,132 @@
1
+ import SwiftUI
2
+
3
+ /// The one empty state of the app: an icon, a title, one sentence, and one
4
+ /// prominent button, the action that fills the list. A second, plainer button
5
+ /// exists for the rare screen that needs it. Every list draws its emptiness with
6
+ /// it, so a user meets the same shape wherever there is nothing to show yet.
7
+ ///
8
+ /// `failure` is the same shape for a list that could not load: the reason in
9
+ /// the sentence, "Réessayer" as the button.
10
+ struct EmptyState: View {
11
+ let systemImage: String
12
+ let title: LocalizedStringKey
13
+ let message: Text
14
+ var primary: Action?
15
+ var secondary: Action?
16
+ /// A symbol of the asset catalog drawn in place of `systemImage`: one of
17
+ /// the app's own, made by `ios/tools/generate-symbols.swift`.
18
+ private var assetSymbol: String?
19
+
20
+ /// A button of the empty state. Async so that a retry can show it is
21
+ /// running rather than being tapped again.
22
+ struct Action {
23
+ let title: LocalizedStringKey
24
+ var systemImage: String?
25
+ let run: () async -> Void
26
+
27
+ init(_ title: LocalizedStringKey, systemImage: String? = nil, run: @escaping () async -> Void) {
28
+ self.title = title
29
+ self.systemImage = systemImage
30
+ self.run = run
31
+ }
32
+ }
33
+
34
+ init(
35
+ systemImage: String,
36
+ title: LocalizedStringKey,
37
+ message: LocalizedStringKey,
38
+ primary: Action? = nil,
39
+ secondary: Action? = nil
40
+ ) {
41
+ self.systemImage = systemImage
42
+ self.title = title
43
+ self.message = Text(message)
44
+ self.primary = primary
45
+ self.secondary = secondary
46
+ }
47
+
48
+ /// For a sentence built at run time — an error, a friend's name.
49
+ init(
50
+ systemImage: String,
51
+ title: LocalizedStringKey,
52
+ verbatim message: String,
53
+ primary: Action? = nil,
54
+ secondary: Action? = nil
55
+ ) {
56
+ self.systemImage = systemImage
57
+ self.title = title
58
+ self.message = Text(verbatim: message)
59
+ self.primary = primary
60
+ self.secondary = secondary
61
+ }
62
+
63
+ /// A list that could not load: what failed, and a retry.
64
+ static func failure(
65
+ _ title: LocalizedStringKey,
66
+ message: String,
67
+ retry: @escaping () async -> Void
68
+ ) -> EmptyState {
69
+ EmptyState(
70
+ systemImage: "wifi.exclamationmark",
71
+ title: title,
72
+ verbatim: message,
73
+ primary: Action("Réessayer", systemImage: "arrow.clockwise", run: retry)
74
+ )
75
+ }
76
+
77
+ var body: some View {
78
+ ContentUnavailableView {
79
+ if let assetSymbol {
80
+ Label(title, image: assetSymbol)
81
+ } else {
82
+ Label(title, systemImage: systemImage)
83
+ }
84
+ } description: {
85
+ message
86
+ } actions: {
87
+ if let primary {
88
+ AsyncButton {
89
+ await primary.run()
90
+ } label: {
91
+ label(of: primary)
92
+ }
93
+ .buttonStyle(.borderedProminent)
94
+ .accessibilityIdentifier("empty-primary")
95
+ }
96
+ if let secondary {
97
+ AsyncButton {
98
+ await secondary.run()
99
+ } label: {
100
+ label(of: secondary)
101
+ }
102
+ .buttonStyle(.bordered)
103
+ .accessibilityIdentifier("empty-secondary")
104
+ }
105
+ }
106
+ }
107
+
108
+ /// The same state, drawn with one of the app's own symbols.
109
+ func assetSymbol(_ name: String) -> EmptyState {
110
+ var drawn = self
111
+ drawn.assetSymbol = name
112
+ return drawn
113
+ }
114
+
115
+ @ViewBuilder
116
+ private func label(of action: Action) -> some View {
117
+ if let systemImage = action.systemImage {
118
+ Label(action.title, systemImage: systemImage)
119
+ } else {
120
+ Text(action.title)
121
+ }
122
+ }
123
+ }
124
+
125
+ #Preview {
126
+ EmptyState(
127
+ systemImage: "tray",
128
+ title: "Rien pour l'instant",
129
+ message: "Ajoutez un premier élément pour commencer.",
130
+ primary: .init("Ajouter", systemImage: "plus") {}
131
+ )
132
+ }
@@ -0,0 +1,29 @@
1
+ import SwiftUI
2
+
3
+ struct LabeledInfoRow: View {
4
+ let title: LocalizedStringKey
5
+ let value: String
6
+ let icon: String
7
+
8
+ var body: some View {
9
+ Label {
10
+ LabeledContent {
11
+ Text(value)
12
+ } label: {
13
+ Text(title)
14
+ }
15
+ } icon: {
16
+ Image(systemName: icon)
17
+ .foregroundStyle(.secondary)
18
+ }
19
+ // Every fact a sheet states can be taken elsewhere with a long press.
20
+ .copyable(value)
21
+ }
22
+ }
23
+
24
+ #Preview {
25
+ List {
26
+ LabeledInfoRow(title: "Prénom", value: "Ada", icon: "person")
27
+ LabeledInfoRow(title: "Ville", value: "Lyon", icon: "map")
28
+ }
29
+ }
@@ -0,0 +1,45 @@
1
+ import SwiftUI
2
+
3
+ /// A label sized for a caption: a small glyph in a narrow column of fixed width,
4
+ /// so that two of them stacked keep their words on the same left edge whatever
5
+ /// the glyphs' own widths. For text that sits inside a row rather than being the
6
+ /// row — beside a cover, under a title — where a list's default label style would
7
+ /// hand each icon the wide, centred column meant for a row of its own.
8
+ struct CaptionLabelStyle: LabelStyle {
9
+ func makeBody(configuration: Configuration) -> some View {
10
+ CaptionLabel(configuration: configuration)
11
+ }
12
+ }
13
+
14
+ /// The style's body, a view of its own for the scaled column: a fixed 14 points
15
+ /// held at the larger text sizes while the glyph grew, which then spilled over
16
+ /// its words.
17
+ private struct CaptionLabel: View {
18
+ let configuration: LabelStyleConfiguration
19
+ @ScaledMetric(relativeTo: .caption) private var iconWidth: CGFloat = 14
20
+
21
+ var body: some View {
22
+ HStack(alignment: .firstTextBaseline, spacing: 4) {
23
+ configuration.icon
24
+ .imageScale(.small)
25
+ .frame(width: iconWidth)
26
+ configuration.title
27
+ }
28
+ }
29
+ }
30
+
31
+ extension LabelStyle where Self == CaptionLabelStyle {
32
+ static var caption: CaptionLabelStyle { CaptionLabelStyle() }
33
+ }
34
+
35
+ #Preview {
36
+ List {
37
+ VStack(alignment: .leading, spacing: 2) {
38
+ Label("Mis à jour hier", systemImage: "clock.arrow.circlepath")
39
+ Label("Lyon", systemImage: "mappin")
40
+ }
41
+ .labelStyle(.caption)
42
+ .font(.caption)
43
+ .foregroundStyle(.secondary)
44
+ }
45
+ }
@@ -0,0 +1,32 @@
1
+ import SwiftUI
2
+ import UIKit
3
+
4
+ /// One piece of text a long press offers to copy, with the words its menu item
5
+ /// says it under.
6
+ struct CopyableValue {
7
+ let title: LocalizedStringKey
8
+ let value: String
9
+ }
10
+
11
+ extension View {
12
+ /// A long press offers to copy `value`: an ISBN to paste in a bookshop, a
13
+ /// title to search for. A context menu rather than selectable text, which a
14
+ /// list row does not offer and a button row would swallow.
15
+ func copyable(_ value: String) -> some View {
16
+ copyable([CopyableValue(title: "Copier", value: value)])
17
+ }
18
+
19
+ /// The same for a row that holds several values — a title and its author
20
+ /// side by side: one menu item each, and none for a value left empty.
21
+ func copyable(_ values: [CopyableValue]) -> some View {
22
+ contextMenu {
23
+ ForEach(Array(values.enumerated()), id: \.offset) { _, entry in
24
+ if !entry.value.isEmpty {
25
+ Button(entry.title, systemImage: "doc.on.doc") {
26
+ UIPasteboard.general.string = entry.value
27
+ }
28
+ }
29
+ }
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,14 @@
1
+ import SwiftUI
2
+
3
+ extension View {
4
+ /// A list row whose separator runs from one edge of the card to the other,
5
+ /// rather than starting at the text: the inset is pinned rather than left
6
+ /// to the list, so the guides know exactly how far the edges are. Put on
7
+ /// the outermost view of the row — a book row followed by a button would
8
+ /// otherwise stop its line short of the button.
9
+ func edgeToEdgeSeparator(inset: CGFloat = 16) -> some View {
10
+ listRowInsets(.horizontal, inset)
11
+ .alignmentGuide(.listRowSeparatorLeading) { $0[.leading] - inset }
12
+ .alignmentGuide(.listRowSeparatorTrailing) { $0[.trailing] + inset }
13
+ }
14
+ }
@@ -0,0 +1,58 @@
1
+ import SwiftUI
2
+
3
+ /// The one way an icon sits beside a row's text, on every form and sheet of the
4
+ /// app: in a column of fixed width, so glyphs of different widths still line
5
+ /// their text up, and level with the first line of text rather than centred on
6
+ /// the row, so a field that wraps onto three lines keeps its icon beside its
7
+ /// title.
8
+ ///
9
+ /// Applied per screen with `.labelStyle(.row)`, which every `Label` under it
10
+ /// inherits — menu items are drawn by the system and keep their own layout.
11
+ struct RowLabelStyle: LabelStyle {
12
+ func makeBody(configuration: Configuration) -> some View {
13
+ RowLabel(configuration: configuration)
14
+ }
15
+ }
16
+
17
+ /// The style's body, a view of its own for the scaled column: held at a fixed
18
+ /// width while the glyph grew with the text, it pushed the icon out past the
19
+ /// row's left edge at the larger sizes.
20
+ private struct RowLabel: View {
21
+ let configuration: LabelStyleConfiguration
22
+ /// Wide enough for the widest symbol the rows use, at every text size.
23
+ @ScaledMetric(relativeTo: .body) private var iconWidth: CGFloat = 26
24
+
25
+ var body: some View {
26
+ HStack(alignment: .firstTextBaseline, spacing: 12) {
27
+ configuration.icon
28
+ .font(.body)
29
+ .imageScale(.medium)
30
+ // A floor, not a width: at the largest sizes the widest glyphs
31
+ // outgrow even the scaled column, and are better given room
32
+ // than pushed out of the row.
33
+ .frame(minWidth: iconWidth)
34
+ configuration.title
35
+ }
36
+ }
37
+ }
38
+
39
+ extension LabelStyle where Self == RowLabelStyle {
40
+ static var row: RowLabelStyle { RowLabelStyle() }
41
+ }
42
+
43
+ #Preview {
44
+ List {
45
+ Label("Ville", systemImage: "building.2")
46
+ Label("Prénom", systemImage: "person")
47
+ Label {
48
+ VStack(alignment: .leading, spacing: 6) {
49
+ Text("Note")
50
+ Text("Une note assez longue pour passer sur deux lignes et montrer où reste l'icône")
51
+ .foregroundStyle(.secondary)
52
+ }
53
+ } icon: {
54
+ Image(systemName: "tag").foregroundStyle(.secondary)
55
+ }
56
+ }
57
+ .labelStyle(.row)
58
+ }