@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,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
|
+
}
|