From 04c186b8d91ca8dd8eaeb210b5c3a4a078b27915 Mon Sep 17 00:00:00 2001 From: Ur-imazing Date: Thu, 26 Feb 2026 09:38:34 +1300 Subject: [PATCH 01/19] chore(ios): add Apollo iOS dependency and codegen config Made-with: Cursor --- mobile/ios/Package.swift | 9 ++++++-- mobile/ios/apollo-codegen-configuration.json | 23 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 mobile/ios/apollo-codegen-configuration.json diff --git a/mobile/ios/Package.swift b/mobile/ios/Package.swift index dae61e85..d9b8345f 100644 --- a/mobile/ios/Package.swift +++ b/mobile/ios/Package.swift @@ -3,14 +3,19 @@ import PackageDescription let package = Package( name: "ForgeMobile", - platforms: [.iOS(.v17)], + platforms: [.iOS(.v17), .macOS(.v12)], products: [ .library(name: "ForgeMobile", targets: ["ForgeMobile"]) ], + dependencies: [ + .package(url: "https://github.com/apollographql/apollo-ios.git", from: "1.0.0") + ], targets: [ .target( name: "ForgeMobile", - dependencies: [], + dependencies: [ + .product(name: "Apollo", package: "apollo-ios") + ], path: "Sources/ForgeMobile" ) ] diff --git a/mobile/ios/apollo-codegen-configuration.json b/mobile/ios/apollo-codegen-configuration.json new file mode 100644 index 00000000..180bdf89 --- /dev/null +++ b/mobile/ios/apollo-codegen-configuration.json @@ -0,0 +1,23 @@ +{ + "schemaNamespace": "ForgeSchema", + "input": { + "schemaSearchPaths": ["../../apps/cms/schema.graphql"], + "operationSearchPaths": ["GraphQL/Operations/**/*.graphql"] + }, + "output": { + "schemaTypes": { + "path": "./Sources/ForgeMobile/Generated", + "moduleType": { + "embeddedInTarget": { + "name": "ForgeMobile" + } + } + }, + "operations": { + "inSchemaModule": {} + }, + "testMocks": { + "none": {} + } + } +} From 1d7d4c532ce2fd959cbef6f5c1d41cbc8bceea5a Mon Sep 17 00:00:00 2001 From: Ur-imazing Date: Thu, 26 Feb 2026 09:38:40 +1300 Subject: [PATCH 02/19] feat(ios): add GetWatchExperience query and generated types Made-with: Cursor --- .../Operations/GetWatchExperience.graphql | 48 ++++ .../Generated/ForgeSchema.graphql.swift | 4 + .../GetWatchExperienceQuery.graphql.swift | 240 ++++++++++++++++++ .../Schema/CustomScalars/DateTime.swift | 14 + .../Schema/CustomScalars/I18NLocaleCode.swift | 14 + .../Generated/Schema/CustomScalars/ID.swift | 14 + ...TIONSMEDIACOLLECTION_VARIANT.graphql.swift | 15 ++ .../BooleanFilterInput.graphql.swift | 175 +++++++++++++ .../DateTimeFilterInput.graphql.swift | 175 +++++++++++++ .../ExperienceFiltersInput.graphql.swift | 98 +++++++ .../InputObjects/IDFilterInput.graphql.swift | 175 +++++++++++++ .../StringFilterInput.graphql.swift | 175 +++++++++++++ .../ComponentSectionsCta.graphql.swift | 12 + .../ComponentSectionsInfoBlock.graphql.swift | 12 + .../ComponentSectionsInfoBlocks.graphql.swift | 12 + ...onentSectionsMediaCollection.graphql.swift | 12 + ...ComponentSectionsPromoBanner.graphql.swift | 12 + .../Schema/Objects/Error.graphql.swift | 12 + .../Schema/Objects/Experience.graphql.swift | 12 + .../Schema/Objects/Query.graphql.swift | 12 + .../Schema/SchemaConfiguration.swift | 15 ++ .../Schema/SchemaMetadata.graphql.swift | 49 ++++ ...xperienceSectionsDynamicZone.graphql.swift | 17 ++ 23 files changed, 1324 insertions(+) create mode 100644 mobile/ios/GraphQL/Operations/GetWatchExperience.graphql create mode 100644 mobile/ios/Sources/ForgeMobile/Generated/ForgeSchema.graphql.swift create mode 100644 mobile/ios/Sources/ForgeMobile/Generated/Operations/Queries/GetWatchExperienceQuery.graphql.swift create mode 100644 mobile/ios/Sources/ForgeMobile/Generated/Schema/CustomScalars/DateTime.swift create mode 100644 mobile/ios/Sources/ForgeMobile/Generated/Schema/CustomScalars/I18NLocaleCode.swift create mode 100644 mobile/ios/Sources/ForgeMobile/Generated/Schema/CustomScalars/ID.swift create mode 100644 mobile/ios/Sources/ForgeMobile/Generated/Schema/Enums/ENUM_COMPONENTSECTIONSMEDIACOLLECTION_VARIANT.graphql.swift create mode 100644 mobile/ios/Sources/ForgeMobile/Generated/Schema/InputObjects/BooleanFilterInput.graphql.swift create mode 100644 mobile/ios/Sources/ForgeMobile/Generated/Schema/InputObjects/DateTimeFilterInput.graphql.swift create mode 100644 mobile/ios/Sources/ForgeMobile/Generated/Schema/InputObjects/ExperienceFiltersInput.graphql.swift create mode 100644 mobile/ios/Sources/ForgeMobile/Generated/Schema/InputObjects/IDFilterInput.graphql.swift create mode 100644 mobile/ios/Sources/ForgeMobile/Generated/Schema/InputObjects/StringFilterInput.graphql.swift create mode 100644 mobile/ios/Sources/ForgeMobile/Generated/Schema/Objects/ComponentSectionsCta.graphql.swift create mode 100644 mobile/ios/Sources/ForgeMobile/Generated/Schema/Objects/ComponentSectionsInfoBlock.graphql.swift create mode 100644 mobile/ios/Sources/ForgeMobile/Generated/Schema/Objects/ComponentSectionsInfoBlocks.graphql.swift create mode 100644 mobile/ios/Sources/ForgeMobile/Generated/Schema/Objects/ComponentSectionsMediaCollection.graphql.swift create mode 100644 mobile/ios/Sources/ForgeMobile/Generated/Schema/Objects/ComponentSectionsPromoBanner.graphql.swift create mode 100644 mobile/ios/Sources/ForgeMobile/Generated/Schema/Objects/Error.graphql.swift create mode 100644 mobile/ios/Sources/ForgeMobile/Generated/Schema/Objects/Experience.graphql.swift create mode 100644 mobile/ios/Sources/ForgeMobile/Generated/Schema/Objects/Query.graphql.swift create mode 100644 mobile/ios/Sources/ForgeMobile/Generated/Schema/SchemaConfiguration.swift create mode 100644 mobile/ios/Sources/ForgeMobile/Generated/Schema/SchemaMetadata.graphql.swift create mode 100644 mobile/ios/Sources/ForgeMobile/Generated/Schema/Unions/ExperienceSectionsDynamicZone.graphql.swift diff --git a/mobile/ios/GraphQL/Operations/GetWatchExperience.graphql b/mobile/ios/GraphQL/Operations/GetWatchExperience.graphql new file mode 100644 index 00000000..d5a7572a --- /dev/null +++ b/mobile/ios/GraphQL/Operations/GetWatchExperience.graphql @@ -0,0 +1,48 @@ +query GetWatchExperience( + $locale: I18NLocaleCode! + $filters: ExperienceFiltersInput! +) { + experiences(filters: $filters, locale: $locale) { + documentId + slug + sections { + __typename + ... on ComponentSectionsMediaCollection { + id + title + subtitle + mediaCollectionDescription: description + categoryLabel + mediaCollectionCtaLink: ctaLink + showItemNumbers + variant + } + ... on ComponentSectionsPromoBanner { + id + promoBannerHeading: heading + promoBannerDescription: description + intro + promoBannerCtaLink: ctaLink + } + ... on ComponentSectionsInfoBlocks { + id + infoBlocksHeading: heading + intro + infoBlocksDescription: description + blocks { + id + title + description + icon + } + } + ... on ComponentSectionsCta { + id + ctaHeading: heading + body + buttonLabel + buttonLink + } + } + } +} diff --git a/mobile/ios/Sources/ForgeMobile/Generated/ForgeSchema.graphql.swift b/mobile/ios/Sources/ForgeMobile/Generated/ForgeSchema.graphql.swift new file mode 100644 index 00000000..401674fb --- /dev/null +++ b/mobile/ios/Sources/ForgeMobile/Generated/ForgeSchema.graphql.swift @@ -0,0 +1,4 @@ +// @generated +// This file was automatically generated and should not be edited. + +enum ForgeSchema { } diff --git a/mobile/ios/Sources/ForgeMobile/Generated/Operations/Queries/GetWatchExperienceQuery.graphql.swift b/mobile/ios/Sources/ForgeMobile/Generated/Operations/Queries/GetWatchExperienceQuery.graphql.swift new file mode 100644 index 00000000..a78ab27a --- /dev/null +++ b/mobile/ios/Sources/ForgeMobile/Generated/Operations/Queries/GetWatchExperienceQuery.graphql.swift @@ -0,0 +1,240 @@ +// @generated +// This file was automatically generated and should not be edited. + +@_exported import ApolloAPI + +extension ForgeSchema { + class GetWatchExperienceQuery: GraphQLQuery { + static let operationName: String = "GetWatchExperience" + static let operationDocument: ApolloAPI.OperationDocument = .init( + definition: .init( + #"query GetWatchExperience($locale: I18NLocaleCode!, $filters: ExperienceFiltersInput!) { experiences(filters: $filters, locale: $locale) { __typename documentId slug sections { __typename ... on ComponentSectionsMediaCollection { id title subtitle mediaCollectionDescription: description categoryLabel mediaCollectionCtaLink: ctaLink showItemNumbers variant } ... on ComponentSectionsPromoBanner { id promoBannerHeading: heading promoBannerDescription: description intro promoBannerCtaLink: ctaLink } ... on ComponentSectionsInfoBlocks { id infoBlocksHeading: heading intro infoBlocksDescription: description blocks { __typename id title description icon } } ... on ComponentSectionsCta { id ctaHeading: heading body buttonLabel buttonLink } } } }"# + )) + + public var locale: I18NLocaleCode + public var filters: ExperienceFiltersInput + + public init( + locale: I18NLocaleCode, + filters: ExperienceFiltersInput + ) { + self.locale = locale + self.filters = filters + } + + public var __variables: Variables? { [ + "locale": locale, + "filters": filters + ] } + + struct Data: ForgeSchema.SelectionSet { + let __data: DataDict + init(_dataDict: DataDict) { __data = _dataDict } + + static var __parentType: any ApolloAPI.ParentType { ForgeSchema.Objects.Query } + static var __selections: [ApolloAPI.Selection] { [ + .field("experiences", [Experience?].self, arguments: [ + "filters": .variable("filters"), + "locale": .variable("locale") + ]), + ] } + static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ + GetWatchExperienceQuery.Data.self + ] } + + var experiences: [Experience?] { __data["experiences"] } + + /// Experience + /// + /// Parent Type: `Experience` + struct Experience: ForgeSchema.SelectionSet { + let __data: DataDict + init(_dataDict: DataDict) { __data = _dataDict } + + static var __parentType: any ApolloAPI.ParentType { ForgeSchema.Objects.Experience } + static var __selections: [ApolloAPI.Selection] { [ + .field("__typename", String.self), + .field("documentId", ForgeSchema.ID.self), + .field("slug", String.self), + .field("sections", [Section?]?.self), + ] } + static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ + GetWatchExperienceQuery.Data.Experience.self + ] } + + var documentId: ForgeSchema.ID { __data["documentId"] } + var slug: String { __data["slug"] } + var sections: [Section?]? { __data["sections"] } + + /// Experience.Section + /// + /// Parent Type: `ExperienceSectionsDynamicZone` + struct Section: ForgeSchema.SelectionSet { + let __data: DataDict + init(_dataDict: DataDict) { __data = _dataDict } + + static var __parentType: any ApolloAPI.ParentType { ForgeSchema.Unions.ExperienceSectionsDynamicZone } + static var __selections: [ApolloAPI.Selection] { [ + .field("__typename", String.self), + .inlineFragment(AsComponentSectionsMediaCollection.self), + .inlineFragment(AsComponentSectionsPromoBanner.self), + .inlineFragment(AsComponentSectionsInfoBlocks.self), + .inlineFragment(AsComponentSectionsCta.self), + ] } + static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ + GetWatchExperienceQuery.Data.Experience.Section.self + ] } + + var asComponentSectionsMediaCollection: AsComponentSectionsMediaCollection? { _asInlineFragment() } + var asComponentSectionsPromoBanner: AsComponentSectionsPromoBanner? { _asInlineFragment() } + var asComponentSectionsInfoBlocks: AsComponentSectionsInfoBlocks? { _asInlineFragment() } + var asComponentSectionsCta: AsComponentSectionsCta? { _asInlineFragment() } + + /// Experience.Section.AsComponentSectionsMediaCollection + /// + /// Parent Type: `ComponentSectionsMediaCollection` + struct AsComponentSectionsMediaCollection: ForgeSchema.InlineFragment { + let __data: DataDict + init(_dataDict: DataDict) { __data = _dataDict } + + typealias RootEntityType = GetWatchExperienceQuery.Data.Experience.Section + static var __parentType: any ApolloAPI.ParentType { ForgeSchema.Objects.ComponentSectionsMediaCollection } + static var __selections: [ApolloAPI.Selection] { [ + .field("id", ForgeSchema.ID.self), + .field("title", String?.self), + .field("subtitle", String?.self), + .field("description", alias: "mediaCollectionDescription", String?.self), + .field("categoryLabel", String?.self), + .field("ctaLink", alias: "mediaCollectionCtaLink", String?.self), + .field("showItemNumbers", Bool?.self), + .field("variant", GraphQLEnum.self), + ] } + static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ + GetWatchExperienceQuery.Data.Experience.Section.self, + GetWatchExperienceQuery.Data.Experience.Section.AsComponentSectionsMediaCollection.self + ] } + + var id: ForgeSchema.ID { __data["id"] } + var title: String? { __data["title"] } + var subtitle: String? { __data["subtitle"] } + var mediaCollectionDescription: String? { __data["mediaCollectionDescription"] } + var categoryLabel: String? { __data["categoryLabel"] } + var mediaCollectionCtaLink: String? { __data["mediaCollectionCtaLink"] } + var showItemNumbers: Bool? { __data["showItemNumbers"] } + var variant: GraphQLEnum { __data["variant"] } + } + + /// Experience.Section.AsComponentSectionsPromoBanner + /// + /// Parent Type: `ComponentSectionsPromoBanner` + struct AsComponentSectionsPromoBanner: ForgeSchema.InlineFragment { + let __data: DataDict + init(_dataDict: DataDict) { __data = _dataDict } + + typealias RootEntityType = GetWatchExperienceQuery.Data.Experience.Section + static var __parentType: any ApolloAPI.ParentType { ForgeSchema.Objects.ComponentSectionsPromoBanner } + static var __selections: [ApolloAPI.Selection] { [ + .field("id", ForgeSchema.ID.self), + .field("heading", alias: "promoBannerHeading", String.self), + .field("description", alias: "promoBannerDescription", String.self), + .field("intro", String?.self), + .field("ctaLink", alias: "promoBannerCtaLink", String.self), + ] } + static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ + GetWatchExperienceQuery.Data.Experience.Section.self, + GetWatchExperienceQuery.Data.Experience.Section.AsComponentSectionsPromoBanner.self + ] } + + var id: ForgeSchema.ID { __data["id"] } + var promoBannerHeading: String { __data["promoBannerHeading"] } + var promoBannerDescription: String { __data["promoBannerDescription"] } + var intro: String? { __data["intro"] } + var promoBannerCtaLink: String { __data["promoBannerCtaLink"] } + } + + /// Experience.Section.AsComponentSectionsInfoBlocks + /// + /// Parent Type: `ComponentSectionsInfoBlocks` + struct AsComponentSectionsInfoBlocks: ForgeSchema.InlineFragment { + let __data: DataDict + init(_dataDict: DataDict) { __data = _dataDict } + + typealias RootEntityType = GetWatchExperienceQuery.Data.Experience.Section + static var __parentType: any ApolloAPI.ParentType { ForgeSchema.Objects.ComponentSectionsInfoBlocks } + static var __selections: [ApolloAPI.Selection] { [ + .field("id", ForgeSchema.ID.self), + .field("heading", alias: "infoBlocksHeading", String?.self), + .field("intro", String?.self), + .field("description", alias: "infoBlocksDescription", String?.self), + .field("blocks", [Block?]?.self), + ] } + static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ + GetWatchExperienceQuery.Data.Experience.Section.self, + GetWatchExperienceQuery.Data.Experience.Section.AsComponentSectionsInfoBlocks.self + ] } + + var id: ForgeSchema.ID { __data["id"] } + var infoBlocksHeading: String? { __data["infoBlocksHeading"] } + var intro: String? { __data["intro"] } + var infoBlocksDescription: String? { __data["infoBlocksDescription"] } + var blocks: [Block?]? { __data["blocks"] } + + /// Experience.Section.AsComponentSectionsInfoBlocks.Block + /// + /// Parent Type: `ComponentSectionsInfoBlock` + struct Block: ForgeSchema.SelectionSet { + let __data: DataDict + init(_dataDict: DataDict) { __data = _dataDict } + + static var __parentType: any ApolloAPI.ParentType { ForgeSchema.Objects.ComponentSectionsInfoBlock } + static var __selections: [ApolloAPI.Selection] { [ + .field("__typename", String.self), + .field("id", ForgeSchema.ID.self), + .field("title", String.self), + .field("description", String.self), + .field("icon", String.self), + ] } + static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ + GetWatchExperienceQuery.Data.Experience.Section.AsComponentSectionsInfoBlocks.Block.self + ] } + + var id: ForgeSchema.ID { __data["id"] } + var title: String { __data["title"] } + var description: String { __data["description"] } + var icon: String { __data["icon"] } + } + } + + /// Experience.Section.AsComponentSectionsCta + /// + /// Parent Type: `ComponentSectionsCta` + struct AsComponentSectionsCta: ForgeSchema.InlineFragment { + let __data: DataDict + init(_dataDict: DataDict) { __data = _dataDict } + + typealias RootEntityType = GetWatchExperienceQuery.Data.Experience.Section + static var __parentType: any ApolloAPI.ParentType { ForgeSchema.Objects.ComponentSectionsCta } + static var __selections: [ApolloAPI.Selection] { [ + .field("id", ForgeSchema.ID.self), + .field("heading", alias: "ctaHeading", String.self), + .field("body", String.self), + .field("buttonLabel", String.self), + .field("buttonLink", String.self), + ] } + static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ + GetWatchExperienceQuery.Data.Experience.Section.self, + GetWatchExperienceQuery.Data.Experience.Section.AsComponentSectionsCta.self + ] } + + var id: ForgeSchema.ID { __data["id"] } + var ctaHeading: String { __data["ctaHeading"] } + var body: String { __data["body"] } + var buttonLabel: String { __data["buttonLabel"] } + var buttonLink: String { __data["buttonLink"] } + } + } + } + } + } + +} \ No newline at end of file diff --git a/mobile/ios/Sources/ForgeMobile/Generated/Schema/CustomScalars/DateTime.swift b/mobile/ios/Sources/ForgeMobile/Generated/Schema/CustomScalars/DateTime.swift new file mode 100644 index 00000000..cf935b50 --- /dev/null +++ b/mobile/ios/Sources/ForgeMobile/Generated/Schema/CustomScalars/DateTime.swift @@ -0,0 +1,14 @@ +// @generated +// This file was automatically generated and can be edited to +// implement advanced custom scalar functionality. +// +// Any changes to this file will not be overwritten by future +// code generation execution. + +import ApolloAPI + +extension ForgeSchema { + /// A date-time string at UTC, such as 2007-12-03T10:15:30Z, compliant with the `date-time` format outlined in section 5.6 of the RFC 3339 profile of the ISO 8601 standard for representation of dates and times using the Gregorian calendar. + typealias DateTime = String + +} \ No newline at end of file diff --git a/mobile/ios/Sources/ForgeMobile/Generated/Schema/CustomScalars/I18NLocaleCode.swift b/mobile/ios/Sources/ForgeMobile/Generated/Schema/CustomScalars/I18NLocaleCode.swift new file mode 100644 index 00000000..73c2bfcf --- /dev/null +++ b/mobile/ios/Sources/ForgeMobile/Generated/Schema/CustomScalars/I18NLocaleCode.swift @@ -0,0 +1,14 @@ +// @generated +// This file was automatically generated and can be edited to +// implement advanced custom scalar functionality. +// +// Any changes to this file will not be overwritten by future +// code generation execution. + +import ApolloAPI + +extension ForgeSchema { + /// A string used to identify an i18n locale + typealias I18NLocaleCode = String + +} \ No newline at end of file diff --git a/mobile/ios/Sources/ForgeMobile/Generated/Schema/CustomScalars/ID.swift b/mobile/ios/Sources/ForgeMobile/Generated/Schema/CustomScalars/ID.swift new file mode 100644 index 00000000..8af7b634 --- /dev/null +++ b/mobile/ios/Sources/ForgeMobile/Generated/Schema/CustomScalars/ID.swift @@ -0,0 +1,14 @@ +// @generated +// This file was automatically generated and can be edited to +// implement advanced custom scalar functionality. +// +// Any changes to this file will not be overwritten by future +// code generation execution. + +import ApolloAPI + +extension ForgeSchema { + /// The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `"4"`) or integer (such as `4`) input value will be accepted as an ID. + typealias ID = String + +} \ No newline at end of file diff --git a/mobile/ios/Sources/ForgeMobile/Generated/Schema/Enums/ENUM_COMPONENTSECTIONSMEDIACOLLECTION_VARIANT.graphql.swift b/mobile/ios/Sources/ForgeMobile/Generated/Schema/Enums/ENUM_COMPONENTSECTIONSMEDIACOLLECTION_VARIANT.graphql.swift new file mode 100644 index 00000000..0513b48f --- /dev/null +++ b/mobile/ios/Sources/ForgeMobile/Generated/Schema/Enums/ENUM_COMPONENTSECTIONSMEDIACOLLECTION_VARIANT.graphql.swift @@ -0,0 +1,15 @@ +// @generated +// This file was automatically generated and should not be edited. + +import ApolloAPI + +extension ForgeSchema { + enum ENUM_COMPONENTSECTIONSMEDIACOLLECTION_VARIANT: String, EnumType { + case carousel = "carousel" + case collection = "collection" + case grid = "grid" + case hero = "hero" + case player = "player" + } + +} \ No newline at end of file diff --git a/mobile/ios/Sources/ForgeMobile/Generated/Schema/InputObjects/BooleanFilterInput.graphql.swift b/mobile/ios/Sources/ForgeMobile/Generated/Schema/InputObjects/BooleanFilterInput.graphql.swift new file mode 100644 index 00000000..96450669 --- /dev/null +++ b/mobile/ios/Sources/ForgeMobile/Generated/Schema/InputObjects/BooleanFilterInput.graphql.swift @@ -0,0 +1,175 @@ +// @generated +// This file was automatically generated and should not be edited. + +import ApolloAPI + +extension ForgeSchema { + struct BooleanFilterInput: InputObject { + private(set) var __data: InputDict + + init(_ data: InputDict) { + __data = data + } + + init( + and: GraphQLNullable<[Bool?]> = nil, + between: GraphQLNullable<[Bool?]> = nil, + contains: GraphQLNullable = nil, + containsi: GraphQLNullable = nil, + endsWith: GraphQLNullable = nil, + eq: GraphQLNullable = nil, + eqi: GraphQLNullable = nil, + gt: GraphQLNullable = nil, + gte: GraphQLNullable = nil, + `in`: GraphQLNullable<[Bool?]> = nil, + lt: GraphQLNullable = nil, + lte: GraphQLNullable = nil, + ne: GraphQLNullable = nil, + nei: GraphQLNullable = nil, + not: GraphQLNullable = nil, + notContains: GraphQLNullable = nil, + notContainsi: GraphQLNullable = nil, + notIn: GraphQLNullable<[Bool?]> = nil, + notNull: GraphQLNullable = nil, + null: GraphQLNullable = nil, + or: GraphQLNullable<[Bool?]> = nil, + startsWith: GraphQLNullable = nil + ) { + __data = InputDict([ + "and": and, + "between": between, + "contains": contains, + "containsi": containsi, + "endsWith": endsWith, + "eq": eq, + "eqi": eqi, + "gt": gt, + "gte": gte, + "in": `in`, + "lt": lt, + "lte": lte, + "ne": ne, + "nei": nei, + "not": not, + "notContains": notContains, + "notContainsi": notContainsi, + "notIn": notIn, + "notNull": notNull, + "null": null, + "or": or, + "startsWith": startsWith + ]) + } + + var and: GraphQLNullable<[Bool?]> { + get { __data["and"] } + set { __data["and"] = newValue } + } + + var between: GraphQLNullable<[Bool?]> { + get { __data["between"] } + set { __data["between"] = newValue } + } + + var contains: GraphQLNullable { + get { __data["contains"] } + set { __data["contains"] = newValue } + } + + var containsi: GraphQLNullable { + get { __data["containsi"] } + set { __data["containsi"] = newValue } + } + + var endsWith: GraphQLNullable { + get { __data["endsWith"] } + set { __data["endsWith"] = newValue } + } + + var eq: GraphQLNullable { + get { __data["eq"] } + set { __data["eq"] = newValue } + } + + var eqi: GraphQLNullable { + get { __data["eqi"] } + set { __data["eqi"] = newValue } + } + + var gt: GraphQLNullable { + get { __data["gt"] } + set { __data["gt"] = newValue } + } + + var gte: GraphQLNullable { + get { __data["gte"] } + set { __data["gte"] = newValue } + } + + var `in`: GraphQLNullable<[Bool?]> { + get { __data["in"] } + set { __data["in"] = newValue } + } + + var lt: GraphQLNullable { + get { __data["lt"] } + set { __data["lt"] = newValue } + } + + var lte: GraphQLNullable { + get { __data["lte"] } + set { __data["lte"] = newValue } + } + + var ne: GraphQLNullable { + get { __data["ne"] } + set { __data["ne"] = newValue } + } + + var nei: GraphQLNullable { + get { __data["nei"] } + set { __data["nei"] = newValue } + } + + var not: GraphQLNullable { + get { __data["not"] } + set { __data["not"] = newValue } + } + + var notContains: GraphQLNullable { + get { __data["notContains"] } + set { __data["notContains"] = newValue } + } + + var notContainsi: GraphQLNullable { + get { __data["notContainsi"] } + set { __data["notContainsi"] = newValue } + } + + var notIn: GraphQLNullable<[Bool?]> { + get { __data["notIn"] } + set { __data["notIn"] = newValue } + } + + var notNull: GraphQLNullable { + get { __data["notNull"] } + set { __data["notNull"] = newValue } + } + + var null: GraphQLNullable { + get { __data["null"] } + set { __data["null"] = newValue } + } + + var or: GraphQLNullable<[Bool?]> { + get { __data["or"] } + set { __data["or"] = newValue } + } + + var startsWith: GraphQLNullable { + get { __data["startsWith"] } + set { __data["startsWith"] = newValue } + } + } + +} \ No newline at end of file diff --git a/mobile/ios/Sources/ForgeMobile/Generated/Schema/InputObjects/DateTimeFilterInput.graphql.swift b/mobile/ios/Sources/ForgeMobile/Generated/Schema/InputObjects/DateTimeFilterInput.graphql.swift new file mode 100644 index 00000000..d246323e --- /dev/null +++ b/mobile/ios/Sources/ForgeMobile/Generated/Schema/InputObjects/DateTimeFilterInput.graphql.swift @@ -0,0 +1,175 @@ +// @generated +// This file was automatically generated and should not be edited. + +import ApolloAPI + +extension ForgeSchema { + struct DateTimeFilterInput: InputObject { + private(set) var __data: InputDict + + init(_ data: InputDict) { + __data = data + } + + init( + and: GraphQLNullable<[DateTime?]> = nil, + between: GraphQLNullable<[DateTime?]> = nil, + contains: GraphQLNullable = nil, + containsi: GraphQLNullable = nil, + endsWith: GraphQLNullable = nil, + eq: GraphQLNullable = nil, + eqi: GraphQLNullable = nil, + gt: GraphQLNullable = nil, + gte: GraphQLNullable = nil, + `in`: GraphQLNullable<[DateTime?]> = nil, + lt: GraphQLNullable = nil, + lte: GraphQLNullable = nil, + ne: GraphQLNullable = nil, + nei: GraphQLNullable = nil, + not: GraphQLNullable = nil, + notContains: GraphQLNullable = nil, + notContainsi: GraphQLNullable = nil, + notIn: GraphQLNullable<[DateTime?]> = nil, + notNull: GraphQLNullable = nil, + null: GraphQLNullable = nil, + or: GraphQLNullable<[DateTime?]> = nil, + startsWith: GraphQLNullable = nil + ) { + __data = InputDict([ + "and": and, + "between": between, + "contains": contains, + "containsi": containsi, + "endsWith": endsWith, + "eq": eq, + "eqi": eqi, + "gt": gt, + "gte": gte, + "in": `in`, + "lt": lt, + "lte": lte, + "ne": ne, + "nei": nei, + "not": not, + "notContains": notContains, + "notContainsi": notContainsi, + "notIn": notIn, + "notNull": notNull, + "null": null, + "or": or, + "startsWith": startsWith + ]) + } + + var and: GraphQLNullable<[DateTime?]> { + get { __data["and"] } + set { __data["and"] = newValue } + } + + var between: GraphQLNullable<[DateTime?]> { + get { __data["between"] } + set { __data["between"] = newValue } + } + + var contains: GraphQLNullable { + get { __data["contains"] } + set { __data["contains"] = newValue } + } + + var containsi: GraphQLNullable { + get { __data["containsi"] } + set { __data["containsi"] = newValue } + } + + var endsWith: GraphQLNullable { + get { __data["endsWith"] } + set { __data["endsWith"] = newValue } + } + + var eq: GraphQLNullable { + get { __data["eq"] } + set { __data["eq"] = newValue } + } + + var eqi: GraphQLNullable { + get { __data["eqi"] } + set { __data["eqi"] = newValue } + } + + var gt: GraphQLNullable { + get { __data["gt"] } + set { __data["gt"] = newValue } + } + + var gte: GraphQLNullable { + get { __data["gte"] } + set { __data["gte"] = newValue } + } + + var `in`: GraphQLNullable<[DateTime?]> { + get { __data["in"] } + set { __data["in"] = newValue } + } + + var lt: GraphQLNullable { + get { __data["lt"] } + set { __data["lt"] = newValue } + } + + var lte: GraphQLNullable { + get { __data["lte"] } + set { __data["lte"] = newValue } + } + + var ne: GraphQLNullable { + get { __data["ne"] } + set { __data["ne"] = newValue } + } + + var nei: GraphQLNullable { + get { __data["nei"] } + set { __data["nei"] = newValue } + } + + var not: GraphQLNullable { + get { __data["not"] } + set { __data["not"] = newValue } + } + + var notContains: GraphQLNullable { + get { __data["notContains"] } + set { __data["notContains"] = newValue } + } + + var notContainsi: GraphQLNullable { + get { __data["notContainsi"] } + set { __data["notContainsi"] = newValue } + } + + var notIn: GraphQLNullable<[DateTime?]> { + get { __data["notIn"] } + set { __data["notIn"] = newValue } + } + + var notNull: GraphQLNullable { + get { __data["notNull"] } + set { __data["notNull"] = newValue } + } + + var null: GraphQLNullable { + get { __data["null"] } + set { __data["null"] = newValue } + } + + var or: GraphQLNullable<[DateTime?]> { + get { __data["or"] } + set { __data["or"] = newValue } + } + + var startsWith: GraphQLNullable { + get { __data["startsWith"] } + set { __data["startsWith"] = newValue } + } + } + +} \ No newline at end of file diff --git a/mobile/ios/Sources/ForgeMobile/Generated/Schema/InputObjects/ExperienceFiltersInput.graphql.swift b/mobile/ios/Sources/ForgeMobile/Generated/Schema/InputObjects/ExperienceFiltersInput.graphql.swift new file mode 100644 index 00000000..20386856 --- /dev/null +++ b/mobile/ios/Sources/ForgeMobile/Generated/Schema/InputObjects/ExperienceFiltersInput.graphql.swift @@ -0,0 +1,98 @@ +// @generated +// This file was automatically generated and should not be edited. + +import ApolloAPI + +extension ForgeSchema { + struct ExperienceFiltersInput: InputObject { + private(set) var __data: InputDict + + init(_ data: InputDict) { + __data = data + } + + init( + and: GraphQLNullable<[ExperienceFiltersInput?]> = nil, + createdAt: GraphQLNullable = nil, + documentId: GraphQLNullable = nil, + isHomepage: GraphQLNullable = nil, + locale: GraphQLNullable = nil, + localizations: GraphQLNullable = nil, + not: GraphQLNullable = nil, + or: GraphQLNullable<[ExperienceFiltersInput?]> = nil, + publishedAt: GraphQLNullable = nil, + slug: GraphQLNullable = nil, + updatedAt: GraphQLNullable = nil + ) { + __data = InputDict([ + "and": and, + "createdAt": createdAt, + "documentId": documentId, + "isHomepage": isHomepage, + "locale": locale, + "localizations": localizations, + "not": not, + "or": or, + "publishedAt": publishedAt, + "slug": slug, + "updatedAt": updatedAt + ]) + } + + var and: GraphQLNullable<[ExperienceFiltersInput?]> { + get { __data["and"] } + set { __data["and"] = newValue } + } + + var createdAt: GraphQLNullable { + get { __data["createdAt"] } + set { __data["createdAt"] = newValue } + } + + var documentId: GraphQLNullable { + get { __data["documentId"] } + set { __data["documentId"] = newValue } + } + + var isHomepage: GraphQLNullable { + get { __data["isHomepage"] } + set { __data["isHomepage"] = newValue } + } + + var locale: GraphQLNullable { + get { __data["locale"] } + set { __data["locale"] = newValue } + } + + var localizations: GraphQLNullable { + get { __data["localizations"] } + set { __data["localizations"] = newValue } + } + + var not: GraphQLNullable { + get { __data["not"] } + set { __data["not"] = newValue } + } + + var or: GraphQLNullable<[ExperienceFiltersInput?]> { + get { __data["or"] } + set { __data["or"] = newValue } + } + + var publishedAt: GraphQLNullable { + get { __data["publishedAt"] } + set { __data["publishedAt"] = newValue } + } + + var slug: GraphQLNullable { + get { __data["slug"] } + set { __data["slug"] = newValue } + } + + var updatedAt: GraphQLNullable { + get { __data["updatedAt"] } + set { __data["updatedAt"] = newValue } + } + } + +} \ No newline at end of file diff --git a/mobile/ios/Sources/ForgeMobile/Generated/Schema/InputObjects/IDFilterInput.graphql.swift b/mobile/ios/Sources/ForgeMobile/Generated/Schema/InputObjects/IDFilterInput.graphql.swift new file mode 100644 index 00000000..65c4293d --- /dev/null +++ b/mobile/ios/Sources/ForgeMobile/Generated/Schema/InputObjects/IDFilterInput.graphql.swift @@ -0,0 +1,175 @@ +// @generated +// This file was automatically generated and should not be edited. + +import ApolloAPI + +extension ForgeSchema { + struct IDFilterInput: InputObject { + private(set) var __data: InputDict + + init(_ data: InputDict) { + __data = data + } + + init( + and: GraphQLNullable<[ID?]> = nil, + between: GraphQLNullable<[ID?]> = nil, + contains: GraphQLNullable = nil, + containsi: GraphQLNullable = nil, + endsWith: GraphQLNullable = nil, + eq: GraphQLNullable = nil, + eqi: GraphQLNullable = nil, + gt: GraphQLNullable = nil, + gte: GraphQLNullable = nil, + `in`: GraphQLNullable<[ID?]> = nil, + lt: GraphQLNullable = nil, + lte: GraphQLNullable = nil, + ne: GraphQLNullable = nil, + nei: GraphQLNullable = nil, + not: GraphQLNullable = nil, + notContains: GraphQLNullable = nil, + notContainsi: GraphQLNullable = nil, + notIn: GraphQLNullable<[ID?]> = nil, + notNull: GraphQLNullable = nil, + null: GraphQLNullable = nil, + or: GraphQLNullable<[ID?]> = nil, + startsWith: GraphQLNullable = nil + ) { + __data = InputDict([ + "and": and, + "between": between, + "contains": contains, + "containsi": containsi, + "endsWith": endsWith, + "eq": eq, + "eqi": eqi, + "gt": gt, + "gte": gte, + "in": `in`, + "lt": lt, + "lte": lte, + "ne": ne, + "nei": nei, + "not": not, + "notContains": notContains, + "notContainsi": notContainsi, + "notIn": notIn, + "notNull": notNull, + "null": null, + "or": or, + "startsWith": startsWith + ]) + } + + var and: GraphQLNullable<[ID?]> { + get { __data["and"] } + set { __data["and"] = newValue } + } + + var between: GraphQLNullable<[ID?]> { + get { __data["between"] } + set { __data["between"] = newValue } + } + + var contains: GraphQLNullable { + get { __data["contains"] } + set { __data["contains"] = newValue } + } + + var containsi: GraphQLNullable { + get { __data["containsi"] } + set { __data["containsi"] = newValue } + } + + var endsWith: GraphQLNullable { + get { __data["endsWith"] } + set { __data["endsWith"] = newValue } + } + + var eq: GraphQLNullable { + get { __data["eq"] } + set { __data["eq"] = newValue } + } + + var eqi: GraphQLNullable { + get { __data["eqi"] } + set { __data["eqi"] = newValue } + } + + var gt: GraphQLNullable { + get { __data["gt"] } + set { __data["gt"] = newValue } + } + + var gte: GraphQLNullable { + get { __data["gte"] } + set { __data["gte"] = newValue } + } + + var `in`: GraphQLNullable<[ID?]> { + get { __data["in"] } + set { __data["in"] = newValue } + } + + var lt: GraphQLNullable { + get { __data["lt"] } + set { __data["lt"] = newValue } + } + + var lte: GraphQLNullable { + get { __data["lte"] } + set { __data["lte"] = newValue } + } + + var ne: GraphQLNullable { + get { __data["ne"] } + set { __data["ne"] = newValue } + } + + var nei: GraphQLNullable { + get { __data["nei"] } + set { __data["nei"] = newValue } + } + + var not: GraphQLNullable { + get { __data["not"] } + set { __data["not"] = newValue } + } + + var notContains: GraphQLNullable { + get { __data["notContains"] } + set { __data["notContains"] = newValue } + } + + var notContainsi: GraphQLNullable { + get { __data["notContainsi"] } + set { __data["notContainsi"] = newValue } + } + + var notIn: GraphQLNullable<[ID?]> { + get { __data["notIn"] } + set { __data["notIn"] = newValue } + } + + var notNull: GraphQLNullable { + get { __data["notNull"] } + set { __data["notNull"] = newValue } + } + + var null: GraphQLNullable { + get { __data["null"] } + set { __data["null"] = newValue } + } + + var or: GraphQLNullable<[ID?]> { + get { __data["or"] } + set { __data["or"] = newValue } + } + + var startsWith: GraphQLNullable { + get { __data["startsWith"] } + set { __data["startsWith"] = newValue } + } + } + +} \ No newline at end of file diff --git a/mobile/ios/Sources/ForgeMobile/Generated/Schema/InputObjects/StringFilterInput.graphql.swift b/mobile/ios/Sources/ForgeMobile/Generated/Schema/InputObjects/StringFilterInput.graphql.swift new file mode 100644 index 00000000..5425016f --- /dev/null +++ b/mobile/ios/Sources/ForgeMobile/Generated/Schema/InputObjects/StringFilterInput.graphql.swift @@ -0,0 +1,175 @@ +// @generated +// This file was automatically generated and should not be edited. + +import ApolloAPI + +extension ForgeSchema { + struct StringFilterInput: InputObject { + private(set) var __data: InputDict + + init(_ data: InputDict) { + __data = data + } + + init( + and: GraphQLNullable<[String?]> = nil, + between: GraphQLNullable<[String?]> = nil, + contains: GraphQLNullable = nil, + containsi: GraphQLNullable = nil, + endsWith: GraphQLNullable = nil, + eq: GraphQLNullable = nil, + eqi: GraphQLNullable = nil, + gt: GraphQLNullable = nil, + gte: GraphQLNullable = nil, + `in`: GraphQLNullable<[String?]> = nil, + lt: GraphQLNullable = nil, + lte: GraphQLNullable = nil, + ne: GraphQLNullable = nil, + nei: GraphQLNullable = nil, + not: GraphQLNullable = nil, + notContains: GraphQLNullable = nil, + notContainsi: GraphQLNullable = nil, + notIn: GraphQLNullable<[String?]> = nil, + notNull: GraphQLNullable = nil, + null: GraphQLNullable = nil, + or: GraphQLNullable<[String?]> = nil, + startsWith: GraphQLNullable = nil + ) { + __data = InputDict([ + "and": and, + "between": between, + "contains": contains, + "containsi": containsi, + "endsWith": endsWith, + "eq": eq, + "eqi": eqi, + "gt": gt, + "gte": gte, + "in": `in`, + "lt": lt, + "lte": lte, + "ne": ne, + "nei": nei, + "not": not, + "notContains": notContains, + "notContainsi": notContainsi, + "notIn": notIn, + "notNull": notNull, + "null": null, + "or": or, + "startsWith": startsWith + ]) + } + + var and: GraphQLNullable<[String?]> { + get { __data["and"] } + set { __data["and"] = newValue } + } + + var between: GraphQLNullable<[String?]> { + get { __data["between"] } + set { __data["between"] = newValue } + } + + var contains: GraphQLNullable { + get { __data["contains"] } + set { __data["contains"] = newValue } + } + + var containsi: GraphQLNullable { + get { __data["containsi"] } + set { __data["containsi"] = newValue } + } + + var endsWith: GraphQLNullable { + get { __data["endsWith"] } + set { __data["endsWith"] = newValue } + } + + var eq: GraphQLNullable { + get { __data["eq"] } + set { __data["eq"] = newValue } + } + + var eqi: GraphQLNullable { + get { __data["eqi"] } + set { __data["eqi"] = newValue } + } + + var gt: GraphQLNullable { + get { __data["gt"] } + set { __data["gt"] = newValue } + } + + var gte: GraphQLNullable { + get { __data["gte"] } + set { __data["gte"] = newValue } + } + + var `in`: GraphQLNullable<[String?]> { + get { __data["in"] } + set { __data["in"] = newValue } + } + + var lt: GraphQLNullable { + get { __data["lt"] } + set { __data["lt"] = newValue } + } + + var lte: GraphQLNullable { + get { __data["lte"] } + set { __data["lte"] = newValue } + } + + var ne: GraphQLNullable { + get { __data["ne"] } + set { __data["ne"] = newValue } + } + + var nei: GraphQLNullable { + get { __data["nei"] } + set { __data["nei"] = newValue } + } + + var not: GraphQLNullable { + get { __data["not"] } + set { __data["not"] = newValue } + } + + var notContains: GraphQLNullable { + get { __data["notContains"] } + set { __data["notContains"] = newValue } + } + + var notContainsi: GraphQLNullable { + get { __data["notContainsi"] } + set { __data["notContainsi"] = newValue } + } + + var notIn: GraphQLNullable<[String?]> { + get { __data["notIn"] } + set { __data["notIn"] = newValue } + } + + var notNull: GraphQLNullable { + get { __data["notNull"] } + set { __data["notNull"] = newValue } + } + + var null: GraphQLNullable { + get { __data["null"] } + set { __data["null"] = newValue } + } + + var or: GraphQLNullable<[String?]> { + get { __data["or"] } + set { __data["or"] = newValue } + } + + var startsWith: GraphQLNullable { + get { __data["startsWith"] } + set { __data["startsWith"] = newValue } + } + } + +} \ No newline at end of file diff --git a/mobile/ios/Sources/ForgeMobile/Generated/Schema/Objects/ComponentSectionsCta.graphql.swift b/mobile/ios/Sources/ForgeMobile/Generated/Schema/Objects/ComponentSectionsCta.graphql.swift new file mode 100644 index 00000000..7469014c --- /dev/null +++ b/mobile/ios/Sources/ForgeMobile/Generated/Schema/Objects/ComponentSectionsCta.graphql.swift @@ -0,0 +1,12 @@ +// @generated +// This file was automatically generated and should not be edited. + +import ApolloAPI + +extension ForgeSchema.Objects { + static let ComponentSectionsCta = ApolloAPI.Object( + typename: "ComponentSectionsCta", + implementedInterfaces: [], + keyFields: nil + ) +} \ No newline at end of file diff --git a/mobile/ios/Sources/ForgeMobile/Generated/Schema/Objects/ComponentSectionsInfoBlock.graphql.swift b/mobile/ios/Sources/ForgeMobile/Generated/Schema/Objects/ComponentSectionsInfoBlock.graphql.swift new file mode 100644 index 00000000..e4d70d86 --- /dev/null +++ b/mobile/ios/Sources/ForgeMobile/Generated/Schema/Objects/ComponentSectionsInfoBlock.graphql.swift @@ -0,0 +1,12 @@ +// @generated +// This file was automatically generated and should not be edited. + +import ApolloAPI + +extension ForgeSchema.Objects { + static let ComponentSectionsInfoBlock = ApolloAPI.Object( + typename: "ComponentSectionsInfoBlock", + implementedInterfaces: [], + keyFields: nil + ) +} \ No newline at end of file diff --git a/mobile/ios/Sources/ForgeMobile/Generated/Schema/Objects/ComponentSectionsInfoBlocks.graphql.swift b/mobile/ios/Sources/ForgeMobile/Generated/Schema/Objects/ComponentSectionsInfoBlocks.graphql.swift new file mode 100644 index 00000000..4bc6a7a0 --- /dev/null +++ b/mobile/ios/Sources/ForgeMobile/Generated/Schema/Objects/ComponentSectionsInfoBlocks.graphql.swift @@ -0,0 +1,12 @@ +// @generated +// This file was automatically generated and should not be edited. + +import ApolloAPI + +extension ForgeSchema.Objects { + static let ComponentSectionsInfoBlocks = ApolloAPI.Object( + typename: "ComponentSectionsInfoBlocks", + implementedInterfaces: [], + keyFields: nil + ) +} \ No newline at end of file diff --git a/mobile/ios/Sources/ForgeMobile/Generated/Schema/Objects/ComponentSectionsMediaCollection.graphql.swift b/mobile/ios/Sources/ForgeMobile/Generated/Schema/Objects/ComponentSectionsMediaCollection.graphql.swift new file mode 100644 index 00000000..b5e6795d --- /dev/null +++ b/mobile/ios/Sources/ForgeMobile/Generated/Schema/Objects/ComponentSectionsMediaCollection.graphql.swift @@ -0,0 +1,12 @@ +// @generated +// This file was automatically generated and should not be edited. + +import ApolloAPI + +extension ForgeSchema.Objects { + static let ComponentSectionsMediaCollection = ApolloAPI.Object( + typename: "ComponentSectionsMediaCollection", + implementedInterfaces: [], + keyFields: nil + ) +} \ No newline at end of file diff --git a/mobile/ios/Sources/ForgeMobile/Generated/Schema/Objects/ComponentSectionsPromoBanner.graphql.swift b/mobile/ios/Sources/ForgeMobile/Generated/Schema/Objects/ComponentSectionsPromoBanner.graphql.swift new file mode 100644 index 00000000..f2abe727 --- /dev/null +++ b/mobile/ios/Sources/ForgeMobile/Generated/Schema/Objects/ComponentSectionsPromoBanner.graphql.swift @@ -0,0 +1,12 @@ +// @generated +// This file was automatically generated and should not be edited. + +import ApolloAPI + +extension ForgeSchema.Objects { + static let ComponentSectionsPromoBanner = ApolloAPI.Object( + typename: "ComponentSectionsPromoBanner", + implementedInterfaces: [], + keyFields: nil + ) +} \ No newline at end of file diff --git a/mobile/ios/Sources/ForgeMobile/Generated/Schema/Objects/Error.graphql.swift b/mobile/ios/Sources/ForgeMobile/Generated/Schema/Objects/Error.graphql.swift new file mode 100644 index 00000000..a81a8321 --- /dev/null +++ b/mobile/ios/Sources/ForgeMobile/Generated/Schema/Objects/Error.graphql.swift @@ -0,0 +1,12 @@ +// @generated +// This file was automatically generated and should not be edited. + +import ApolloAPI + +extension ForgeSchema.Objects { + static let Error_Object = ApolloAPI.Object( + typename: "Error", + implementedInterfaces: [], + keyFields: nil + ) +} \ No newline at end of file diff --git a/mobile/ios/Sources/ForgeMobile/Generated/Schema/Objects/Experience.graphql.swift b/mobile/ios/Sources/ForgeMobile/Generated/Schema/Objects/Experience.graphql.swift new file mode 100644 index 00000000..6898e1c3 --- /dev/null +++ b/mobile/ios/Sources/ForgeMobile/Generated/Schema/Objects/Experience.graphql.swift @@ -0,0 +1,12 @@ +// @generated +// This file was automatically generated and should not be edited. + +import ApolloAPI + +extension ForgeSchema.Objects { + static let Experience = ApolloAPI.Object( + typename: "Experience", + implementedInterfaces: [], + keyFields: nil + ) +} \ No newline at end of file diff --git a/mobile/ios/Sources/ForgeMobile/Generated/Schema/Objects/Query.graphql.swift b/mobile/ios/Sources/ForgeMobile/Generated/Schema/Objects/Query.graphql.swift new file mode 100644 index 00000000..1ab3def7 --- /dev/null +++ b/mobile/ios/Sources/ForgeMobile/Generated/Schema/Objects/Query.graphql.swift @@ -0,0 +1,12 @@ +// @generated +// This file was automatically generated and should not be edited. + +import ApolloAPI + +extension ForgeSchema.Objects { + static let Query = ApolloAPI.Object( + typename: "Query", + implementedInterfaces: [], + keyFields: nil + ) +} \ No newline at end of file diff --git a/mobile/ios/Sources/ForgeMobile/Generated/Schema/SchemaConfiguration.swift b/mobile/ios/Sources/ForgeMobile/Generated/Schema/SchemaConfiguration.swift new file mode 100644 index 00000000..7afdf3cd --- /dev/null +++ b/mobile/ios/Sources/ForgeMobile/Generated/Schema/SchemaConfiguration.swift @@ -0,0 +1,15 @@ +// @generated +// This file was automatically generated and can be edited to +// provide custom configuration for a generated GraphQL schema. +// +// Any changes to this file will not be overwritten by future +// code generation execution. + +import ApolloAPI + +enum SchemaConfiguration: ApolloAPI.SchemaConfiguration { + static func cacheKeyInfo(for type: ApolloAPI.Object, object: ApolloAPI.ObjectData) -> CacheKeyInfo? { + // Implement this function to configure cache key resolution for your schema types. + return nil + } +} diff --git a/mobile/ios/Sources/ForgeMobile/Generated/Schema/SchemaMetadata.graphql.swift b/mobile/ios/Sources/ForgeMobile/Generated/Schema/SchemaMetadata.graphql.swift new file mode 100644 index 00000000..6a3b0793 --- /dev/null +++ b/mobile/ios/Sources/ForgeMobile/Generated/Schema/SchemaMetadata.graphql.swift @@ -0,0 +1,49 @@ +// @generated +// This file was automatically generated and should not be edited. + +import ApolloAPI + +protocol ForgeSchema_SelectionSet: ApolloAPI.SelectionSet & ApolloAPI.RootSelectionSet +where Schema == ForgeSchema.SchemaMetadata {} + +protocol ForgeSchema_InlineFragment: ApolloAPI.SelectionSet & ApolloAPI.InlineFragment +where Schema == ForgeSchema.SchemaMetadata {} + +protocol ForgeSchema_MutableSelectionSet: ApolloAPI.MutableRootSelectionSet +where Schema == ForgeSchema.SchemaMetadata {} + +protocol ForgeSchema_MutableInlineFragment: ApolloAPI.MutableSelectionSet & ApolloAPI.InlineFragment +where Schema == ForgeSchema.SchemaMetadata {} + +extension ForgeSchema { + typealias SelectionSet = ForgeSchema_SelectionSet + + typealias InlineFragment = ForgeSchema_InlineFragment + + typealias MutableSelectionSet = ForgeSchema_MutableSelectionSet + + typealias MutableInlineFragment = ForgeSchema_MutableInlineFragment + + enum SchemaMetadata: ApolloAPI.SchemaMetadata { + static let configuration: any ApolloAPI.SchemaConfiguration.Type = SchemaConfiguration.self + + static func objectType(forTypename typename: String) -> ApolloAPI.Object? { + switch typename { + case "ComponentSectionsCta": return ForgeSchema.Objects.ComponentSectionsCta + case "ComponentSectionsInfoBlock": return ForgeSchema.Objects.ComponentSectionsInfoBlock + case "ComponentSectionsInfoBlocks": return ForgeSchema.Objects.ComponentSectionsInfoBlocks + case "ComponentSectionsMediaCollection": return ForgeSchema.Objects.ComponentSectionsMediaCollection + case "ComponentSectionsPromoBanner": return ForgeSchema.Objects.ComponentSectionsPromoBanner + case "Error": return ForgeSchema.Objects.Error_Object + case "Experience": return ForgeSchema.Objects.Experience + case "Query": return ForgeSchema.Objects.Query + default: return nil + } + } + } + + enum Objects {} + enum Interfaces {} + enum Unions {} + +} \ No newline at end of file diff --git a/mobile/ios/Sources/ForgeMobile/Generated/Schema/Unions/ExperienceSectionsDynamicZone.graphql.swift b/mobile/ios/Sources/ForgeMobile/Generated/Schema/Unions/ExperienceSectionsDynamicZone.graphql.swift new file mode 100644 index 00000000..6e0a795d --- /dev/null +++ b/mobile/ios/Sources/ForgeMobile/Generated/Schema/Unions/ExperienceSectionsDynamicZone.graphql.swift @@ -0,0 +1,17 @@ +// @generated +// This file was automatically generated and should not be edited. + +import ApolloAPI + +extension ForgeSchema.Unions { + static let ExperienceSectionsDynamicZone = Union( + name: "ExperienceSectionsDynamicZone", + possibleTypes: [ + ForgeSchema.Objects.ComponentSectionsCta.self, + ForgeSchema.Objects.ComponentSectionsInfoBlocks.self, + ForgeSchema.Objects.ComponentSectionsMediaCollection.self, + ForgeSchema.Objects.ComponentSectionsPromoBanner.self, + ForgeSchema.Objects.Error_Object.self + ] + ) +} \ No newline at end of file From 5e5f4a7a57def7c297715907c720fe894d540d71 Mon Sep 17 00:00:00 2001 From: Ur-imazing Date: Thu, 26 Feb 2026 09:38:44 +1300 Subject: [PATCH 03/19] feat(ios): add GraphQLContentClient adapter for ContentClient Made-with: Cursor --- .../ForgeMobile/GraphQLContentClient.swift | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 mobile/ios/Sources/ForgeMobile/GraphQLContentClient.swift diff --git a/mobile/ios/Sources/ForgeMobile/GraphQLContentClient.swift b/mobile/ios/Sources/ForgeMobile/GraphQLContentClient.swift new file mode 100644 index 00000000..df371754 --- /dev/null +++ b/mobile/ios/Sources/ForgeMobile/GraphQLContentClient.swift @@ -0,0 +1,92 @@ +import Apollo +import ApolloAPI +import Foundation + +/// GraphQL client that implements `ContentClient` by calling the CMS GetWatchExperience query. +/// Configure with endpoint URL and optional bearer token (e.g. for dev/stage/prod). +public final class GraphQLContentClient: ContentClient { + private let apollo: ApolloClient + + public init(endpoint: URL, bearerToken: String? = nil) { + let store = ApolloStore(cache: InMemoryNormalizedCache()) + let headers: [String: String] = if let token = bearerToken { + ["Authorization": "Bearer \(token)"] + } else { + [:] + } + let transport = RequestChainNetworkTransport( + interceptorProvider: DefaultInterceptorProvider(store: store), + endpointURL: endpoint, + additionalHeaders: headers + ) + self.apollo = ApolloClient(networkTransport: transport, store: store) + } + + public func getContent(locale: String, slug: String) async throws -> MobileContentItem? { + let filters = ForgeSchema.ExperienceFiltersInput( + slug: .some(ForgeSchema.StringFilterInput(eq: .some(slug))) + ) + let query = ForgeSchema.GetWatchExperienceQuery( + locale: locale, + filters: filters + ) + let result = await withCheckedContinuation { (continuation: CheckedContinuation, Error>, Never>) in + apollo.fetch(query: query, cachePolicy: .fetchIgnoringCacheData) { result in + continuation.resume(returning: result) + } + } + switch result { + case .success(let graphQLResult): + guard let data = graphQLResult.data else { + if let errors = graphQLResult.errors, !errors.isEmpty { + throw GraphQLContentClientError.graphQLErrors(errors) + } + return nil + } + guard let first = data.experiences.compactMap({ $0 }).first else { + return nil + } + return mapExperienceToContentItem(experience: first, locale: locale) + case .failure(let error): + throw error + } + } + + private func mapExperienceToContentItem( + experience: ForgeSchema.GetWatchExperienceQuery.Data.Experience, + locale: String + ) -> MobileContentItem { + let title = firstSectionTitle(from: experience.sections) ?? experience.slug + return MobileContentItem( + id: String(experience.documentId), + slug: experience.slug, + locale: locale, + title: title, + body: "", + state: "published" + ) + } + + private func firstSectionTitle(from sections: [ForgeSchema.GetWatchExperienceQuery.Data.Experience.Section?]?) -> String? { + guard let sections = sections else { return nil } + for section in sections.compactMap({ $0 }) { + if let media = section.asComponentSectionsMediaCollection, let t = media.title, !t.isEmpty { + return t + } + if let promo = section.asComponentSectionsPromoBanner { + return promo.promoBannerHeading + } + if let info = section.asComponentSectionsInfoBlocks, let t = info.infoBlocksHeading, !t.isEmpty { + return t + } + if let cta = section.asComponentSectionsCta, !cta.ctaHeading.isEmpty { + return cta.ctaHeading + } + } + return nil + } +} + +public enum GraphQLContentClientError: Error { + case graphQLErrors([GraphQLError]) +} From eeedf0c3eb1504424d7802d69dca508f182613c4 Mon Sep 17 00:00:00 2001 From: Ur-imazing Date: Thu, 26 Feb 2026 09:38:56 +1300 Subject: [PATCH 04/19] docs(ios): add GraphQL and codegen section to readme, ignore Apollo CLI symlink Made-with: Cursor --- mobile/ios/.gitignore | 2 ++ mobile/ios/Package.resolved | 14 ++++++++++++++ mobile/ios/README.md | 19 ++++++++++++++++++- 3 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 mobile/ios/.gitignore create mode 100644 mobile/ios/Package.resolved diff --git a/mobile/ios/.gitignore b/mobile/ios/.gitignore new file mode 100644 index 00000000..6ee8bae9 --- /dev/null +++ b/mobile/ios/.gitignore @@ -0,0 +1,2 @@ +# Apollo iOS CLI symlink (created by apollo-cli-install) +apollo-ios-cli diff --git a/mobile/ios/Package.resolved b/mobile/ios/Package.resolved new file mode 100644 index 00000000..32bd22ba --- /dev/null +++ b/mobile/ios/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "apollo-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apollographql/apollo-ios.git", + "state" : { + "revision" : "1abd62f6cbc8c1f4405919d7eb6cd8e96967b07c", + "version" : "1.25.3" + } + } + ], + "version" : 2 +} diff --git a/mobile/ios/README.md b/mobile/ios/README.md index 6102bec7..ede88fd2 100644 --- a/mobile/ios/README.md +++ b/mobile/ios/README.md @@ -2,7 +2,7 @@ Native SwiftUI app. Outside Turborepo graph. -Integrates via ContentClient; implement using `packages/graphql` GraphQL types. +Integrates via **ContentClient**. A GraphQL implementation is provided: **GraphQLContentClient** (endpoint URL + optional bearer token). Construct it with your CMS GraphQL URL (e.g. dev/stage/prod) and pass it to `ContentRepository(client:)`. ## Building and running @@ -30,3 +30,20 @@ xcodebuild -project App/ForgeApp.xcodeproj -scheme ForgeApp -destination 'platfo # Build for a generic iOS Simulator destination xcodebuild -project App/ForgeApp.xcodeproj -scheme ForgeApp -destination 'generic/platform=iOS Simulator' build ``` + +## GraphQL and codegen + +The **ForgeMobile** package uses [Apollo iOS](https://www.apollographql.com/docs/ios/) for the CMS GraphQL client. Schema and operations: + +- **Schema**: `apps/cms/schema.graphql` (repo root). +- **Operations**: `GraphQL/Operations/*.graphql` (e.g. `GetWatchExperience.graphql`). +- **Generated Swift**: `Sources/ForgeMobile/Generated/` (do not edit by hand). + +To regenerate after schema or operation changes, from `mobile/ios`: + +1. Install the Apollo CLI (once): + `swift package --allow-writing-to-package-directory --allow-network-connections all apollo-cli-install` +2. Generate: + `./apollo-ios-cli generate -p apollo-codegen-configuration.json` + +Config: `apollo-codegen-configuration.json` (embedded in ForgeMobile target, schema path points at `../../apps/cms/schema.graphql`). From 0cf602441e00a7fe7a93ad301cb3126a474dd269 Mon Sep 17 00:00:00 2001 From: Ur-imazing Date: Thu, 26 Feb 2026 09:45:46 +1300 Subject: [PATCH 05/19] fix(ci): pass STRAPI_API_TOKEN and NEXT_PUBLIC_GRAPHQL_URL to web build via .env.local Made-with: Cursor --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06fe18f6..b650900e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,7 +104,10 @@ jobs: - name: Test run: pnpm test - name: Build - run: pnpm build + run: | + echo "STRAPI_API_TOKEN=$STRAPI_API_TOKEN" >> apps/web/.env.local + echo "NEXT_PUBLIC_GRAPHQL_URL=$NEXT_PUBLIC_GRAPHQL_URL" >> apps/web/.env.local + pnpm build lint-ios: runs-on: macos-latest From 2a3b753b5fa31f666aac0a5b0fc4fe893196acc1 Mon Sep 17 00:00:00 2001 From: Ur-imazing Date: Thu, 26 Feb 2026 09:49:52 +1300 Subject: [PATCH 06/19] chore(mobile-ios): codegen cwd requirement in readme, iOS-only package Made-with: Cursor --- mobile/ios/Package.swift | 2 +- mobile/ios/README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mobile/ios/Package.swift b/mobile/ios/Package.swift index d9b8345f..e9ba5cc2 100644 --- a/mobile/ios/Package.swift +++ b/mobile/ios/Package.swift @@ -3,7 +3,7 @@ import PackageDescription let package = Package( name: "ForgeMobile", - platforms: [.iOS(.v17), .macOS(.v12)], + platforms: [.iOS(.v17)], products: [ .library(name: "ForgeMobile", targets: ["ForgeMobile"]) ], diff --git a/mobile/ios/README.md b/mobile/ios/README.md index ede88fd2..ed4a85bb 100644 --- a/mobile/ios/README.md +++ b/mobile/ios/README.md @@ -39,11 +39,11 @@ The **ForgeMobile** package uses [Apollo iOS](https://www.apollographql.com/docs - **Operations**: `GraphQL/Operations/*.graphql` (e.g. `GetWatchExperience.graphql`). - **Generated Swift**: `Sources/ForgeMobile/Generated/` (do not edit by hand). -To regenerate after schema or operation changes, from `mobile/ios`: +To regenerate after schema or operation changes, **run from `mobile/ios`** (required: the schema path in config is relative and is resolved from the current working directory): 1. Install the Apollo CLI (once): `swift package --allow-writing-to-package-directory --allow-network-connections all apollo-cli-install` 2. Generate: `./apollo-ios-cli generate -p apollo-codegen-configuration.json` -Config: `apollo-codegen-configuration.json` (embedded in ForgeMobile target, schema path points at `../../apps/cms/schema.graphql`). +Config: `apollo-codegen-configuration.json` (embedded in ForgeMobile target; `schemaSearchPaths` uses `../../apps/cms/schema.graphql`). Any CI or script that runs codegen must use `mobile/ios` as the working directory. From e95cc9b05e7a7127437485e51c6f33119283eaa0 Mon Sep 17 00:00:00 2001 From: Ur-imazing Date: Thu, 26 Feb 2026 09:49:57 +1300 Subject: [PATCH 07/19] fix(mobile-ios): document stubbed body/state, only use non-empty PromoBanner heading Made-with: Cursor --- mobile/ios/Sources/ForgeMobile/GraphQLContentClient.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mobile/ios/Sources/ForgeMobile/GraphQLContentClient.swift b/mobile/ios/Sources/ForgeMobile/GraphQLContentClient.swift index df371754..de041a3b 100644 --- a/mobile/ios/Sources/ForgeMobile/GraphQLContentClient.swift +++ b/mobile/ios/Sources/ForgeMobile/GraphQLContentClient.swift @@ -57,6 +57,8 @@ public final class GraphQLContentClient: ContentClient { locale: String ) -> MobileContentItem { let title = firstSectionTitle(from: experience.sections) ?? experience.slug + // body and state are stubbed: Experience has no root-level body; publication state is not in the query. + // MobileContentItem is a simplified DTO; callers should not rely on state for access control. return MobileContentItem( id: String(experience.documentId), slug: experience.slug, @@ -73,7 +75,7 @@ public final class GraphQLContentClient: ContentClient { if let media = section.asComponentSectionsMediaCollection, let t = media.title, !t.isEmpty { return t } - if let promo = section.asComponentSectionsPromoBanner { + if let promo = section.asComponentSectionsPromoBanner, !promo.promoBannerHeading.isEmpty { return promo.promoBannerHeading } if let info = section.asComponentSectionsInfoBlocks, let t = info.infoBlocksHeading, !t.isEmpty { From da2260a78626b9f3bf105d4c00b0da8bb2b0782f Mon Sep 17 00:00:00 2001 From: Ur-imazing Date: Thu, 26 Feb 2026 10:25:19 +1300 Subject: [PATCH 08/19] fix(mobile-ios): derive state from publishedAt, document stubbed body Made-with: Cursor --- mobile/ios/GraphQL/Operations/GetWatchExperience.graphql | 1 + .../Queries/GetWatchExperienceQuery.graphql.swift | 4 +++- mobile/ios/Sources/ForgeMobile/GraphQLContentClient.swift | 6 +++--- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/mobile/ios/GraphQL/Operations/GetWatchExperience.graphql b/mobile/ios/GraphQL/Operations/GetWatchExperience.graphql index d5a7572a..ef39b3bd 100644 --- a/mobile/ios/GraphQL/Operations/GetWatchExperience.graphql +++ b/mobile/ios/GraphQL/Operations/GetWatchExperience.graphql @@ -5,6 +5,7 @@ query GetWatchExperience( experiences(filters: $filters, locale: $locale) { documentId slug + publishedAt sections { __typename ... on ComponentSectionsMediaCollection { diff --git a/mobile/ios/Sources/ForgeMobile/Generated/Operations/Queries/GetWatchExperienceQuery.graphql.swift b/mobile/ios/Sources/ForgeMobile/Generated/Operations/Queries/GetWatchExperienceQuery.graphql.swift index a78ab27a..514ba036 100644 --- a/mobile/ios/Sources/ForgeMobile/Generated/Operations/Queries/GetWatchExperienceQuery.graphql.swift +++ b/mobile/ios/Sources/ForgeMobile/Generated/Operations/Queries/GetWatchExperienceQuery.graphql.swift @@ -8,7 +8,7 @@ extension ForgeSchema { static let operationName: String = "GetWatchExperience" static let operationDocument: ApolloAPI.OperationDocument = .init( definition: .init( - #"query GetWatchExperience($locale: I18NLocaleCode!, $filters: ExperienceFiltersInput!) { experiences(filters: $filters, locale: $locale) { __typename documentId slug sections { __typename ... on ComponentSectionsMediaCollection { id title subtitle mediaCollectionDescription: description categoryLabel mediaCollectionCtaLink: ctaLink showItemNumbers variant } ... on ComponentSectionsPromoBanner { id promoBannerHeading: heading promoBannerDescription: description intro promoBannerCtaLink: ctaLink } ... on ComponentSectionsInfoBlocks { id infoBlocksHeading: heading intro infoBlocksDescription: description blocks { __typename id title description icon } } ... on ComponentSectionsCta { id ctaHeading: heading body buttonLabel buttonLink } } } }"# + #"query GetWatchExperience($locale: I18NLocaleCode!, $filters: ExperienceFiltersInput!) { experiences(filters: $filters, locale: $locale) { __typename documentId slug publishedAt sections { __typename ... on ComponentSectionsMediaCollection { id title subtitle mediaCollectionDescription: description categoryLabel mediaCollectionCtaLink: ctaLink showItemNumbers variant } ... on ComponentSectionsPromoBanner { id promoBannerHeading: heading promoBannerDescription: description intro promoBannerCtaLink: ctaLink } ... on ComponentSectionsInfoBlocks { id infoBlocksHeading: heading intro infoBlocksDescription: description blocks { __typename id title description icon } } ... on ComponentSectionsCta { id ctaHeading: heading body buttonLabel buttonLink } } } }"# )) public var locale: I18NLocaleCode @@ -56,6 +56,7 @@ extension ForgeSchema { .field("__typename", String.self), .field("documentId", ForgeSchema.ID.self), .field("slug", String.self), + .field("publishedAt", ForgeSchema.DateTime?.self), .field("sections", [Section?]?.self), ] } static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ @@ -64,6 +65,7 @@ extension ForgeSchema { var documentId: ForgeSchema.ID { __data["documentId"] } var slug: String { __data["slug"] } + var publishedAt: ForgeSchema.DateTime? { __data["publishedAt"] } var sections: [Section?]? { __data["sections"] } /// Experience.Section diff --git a/mobile/ios/Sources/ForgeMobile/GraphQLContentClient.swift b/mobile/ios/Sources/ForgeMobile/GraphQLContentClient.swift index de041a3b..3027b9e5 100644 --- a/mobile/ios/Sources/ForgeMobile/GraphQLContentClient.swift +++ b/mobile/ios/Sources/ForgeMobile/GraphQLContentClient.swift @@ -57,15 +57,15 @@ public final class GraphQLContentClient: ContentClient { locale: String ) -> MobileContentItem { let title = firstSectionTitle(from: experience.sections) ?? experience.slug - // body and state are stubbed: Experience has no root-level body; publication state is not in the query. - // MobileContentItem is a simplified DTO; callers should not rely on state for access control. + let state = experience.publishedAt != nil ? "published" : "draft" + // body: Experience has no root-level body in the schema; not requested in the query. Leave empty. return MobileContentItem( id: String(experience.documentId), slug: experience.slug, locale: locale, title: title, body: "", - state: "published" + state: state ) } From 87878bc170796e7b339ac7135b5c65c1b0b81b41 Mon Sep 17 00:00:00 2001 From: Ur-imazing Date: Thu, 26 Feb 2026 10:31:05 +1300 Subject: [PATCH 09/19] revert(ci): remove .env.local workaround from build step Made-with: Cursor --- .github/workflows/ci.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b650900e..06fe18f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,10 +104,7 @@ jobs: - name: Test run: pnpm test - name: Build - run: | - echo "STRAPI_API_TOKEN=$STRAPI_API_TOKEN" >> apps/web/.env.local - echo "NEXT_PUBLIC_GRAPHQL_URL=$NEXT_PUBLIC_GRAPHQL_URL" >> apps/web/.env.local - pnpm build + run: pnpm build lint-ios: runs-on: macos-latest From 20ac8a9573fadcde2ea8a3088b08496d4ffb5980 Mon Sep 17 00:00:00 2001 From: Ur-imazing Date: Thu, 26 Feb 2026 10:42:16 +1300 Subject: [PATCH 10/19] feat(mobile-ios): add init(apollo:) for DI and fix SwiftLint in GraphQLContentClient Made-with: Cursor --- .../ForgeMobile/GraphQLContentClient.swift | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/mobile/ios/Sources/ForgeMobile/GraphQLContentClient.swift b/mobile/ios/Sources/ForgeMobile/GraphQLContentClient.swift index 3027b9e5..6333407d 100644 --- a/mobile/ios/Sources/ForgeMobile/GraphQLContentClient.swift +++ b/mobile/ios/Sources/ForgeMobile/GraphQLContentClient.swift @@ -7,7 +7,13 @@ import Foundation public final class GraphQLContentClient: ContentClient { private let apollo: ApolloClient - public init(endpoint: URL, bearerToken: String? = nil) { + /// Use this initializer to inject an `ApolloClient` (e.g. for tests with a mock). + public init(apollo: ApolloClient) { + self.apollo = apollo + } + + /// Creates a client that talks to the given endpoint with optional bearer auth. + public convenience init(endpoint: URL, bearerToken: String? = nil) { let store = ApolloStore(cache: InMemoryNormalizedCache()) let headers: [String: String] = if let token = bearerToken { ["Authorization": "Bearer \(token)"] @@ -19,7 +25,8 @@ public final class GraphQLContentClient: ContentClient { endpointURL: endpoint, additionalHeaders: headers ) - self.apollo = ApolloClient(networkTransport: transport, store: store) + let apollo = ApolloClient(networkTransport: transport, store: store) + self.init(apollo: apollo) } public func getContent(locale: String, slug: String) async throws -> MobileContentItem? { @@ -30,7 +37,10 @@ public final class GraphQLContentClient: ContentClient { locale: locale, filters: filters ) - let result = await withCheckedContinuation { (continuation: CheckedContinuation, Error>, Never>) in + typealias Continuation = CheckedContinuation< + Result, Error>, Never + > + let result = await withCheckedContinuation { (continuation: Continuation) in apollo.fetch(query: query, cachePolicy: .fetchIgnoringCacheData) { result in continuation.resume(returning: result) } @@ -69,17 +79,21 @@ public final class GraphQLContentClient: ContentClient { ) } - private func firstSectionTitle(from sections: [ForgeSchema.GetWatchExperienceQuery.Data.Experience.Section?]?) -> String? { + private func firstSectionTitle( + from sections: [ForgeSchema.GetWatchExperienceQuery.Data.Experience.Section?]? + ) -> String? { guard let sections = sections else { return nil } for section in sections.compactMap({ $0 }) { - if let media = section.asComponentSectionsMediaCollection, let t = media.title, !t.isEmpty { - return t + if let media = section.asComponentSectionsMediaCollection, + let title = media.title, !title.isEmpty { + return title } if let promo = section.asComponentSectionsPromoBanner, !promo.promoBannerHeading.isEmpty { return promo.promoBannerHeading } - if let info = section.asComponentSectionsInfoBlocks, let t = info.infoBlocksHeading, !t.isEmpty { - return t + if let info = section.asComponentSectionsInfoBlocks, + let heading = info.infoBlocksHeading, !heading.isEmpty { + return heading } if let cta = section.asComponentSectionsCta, !cta.ctaHeading.isEmpty { return cta.ctaHeading From 369ae3f37f535ea498b59e7289f33c80dcfa8d96 Mon Sep 17 00:00:00 2001 From: Ur-imazing Date: Thu, 26 Feb 2026 11:46:24 +1300 Subject: [PATCH 11/19] feat(mobile-ios): test call, Strapi token from .env at build, GraphQL error text - ForgeRootView: optional ContentRepository, fetch home on appear, show result/error - ForgeApp: GraphQL client with token from env or build-generated StrapiToken.swift - Run Script: read STRAPI_FULL_ACCESS_TOKEN from apps/cms/.env, write Generated/StrapiToken.swift - GraphQLContentClientError: LocalizedError with real Strapi error message - Info.plist: ATS exception for localhost; gitignore Generated/ Made-with: Cursor --- .gitignore | 3 ++ apps/cms/.env.example | 3 ++ .../App/ForgeApp.xcodeproj/project.pbxproj | 36 +++++++++++++ mobile/ios/App/ForgeApp/ForgeApp.swift | 12 ++++- mobile/ios/App/ForgeApp/Info.plist | 17 ++++++ mobile/ios/README.md | 2 + .../ForgeMobile/ContentRepository.swift | 2 +- .../Sources/ForgeMobile/ForgeRootView.swift | 54 +++++++++++++++++-- .../ForgeMobile/GraphQLContentClient.swift | 10 +++- 9 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 mobile/ios/App/ForgeApp/Info.plist diff --git a/.gitignore b/.gitignore index 7dd042bb..051c6bb2 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ terraform.tfstate.* # Xcode user state (do not commit) **/xcuserdata/ **/*.xcuserstate + +# iOS app generated token (from apps/cms/.env at build time) +mobile/ios/App/ForgeApp/Generated/ diff --git a/apps/cms/.env.example b/apps/cms/.env.example index cfced4c1..fb798dd8 100644 --- a/apps/cms/.env.example +++ b/apps/cms/.env.example @@ -11,6 +11,9 @@ ENCRYPTION_KEY= DATABASE_CLIENT=sqlite DATABASE_FILENAME=.tmp/data.db +# Full-access API token for local Strapi (clients use as Bearer token; create in Admin → Settings → API Tokens) +STRAPI_FULL_ACCESS_TOKEN= + # Web integration (apps/web expects these; set in apps/web/.env) # Revalidate: web POST /api/revalidate expects x-forge-revalidate-token header (future Strapi webhook on publish) STRAPI_REVALIDATE_TOKEN= diff --git a/mobile/ios/App/ForgeApp.xcodeproj/project.pbxproj b/mobile/ios/App/ForgeApp.xcodeproj/project.pbxproj index dbca0a3c..17b5eff8 100644 --- a/mobile/ios/App/ForgeApp.xcodeproj/project.pbxproj +++ b/mobile/ios/App/ForgeApp.xcodeproj/project.pbxproj @@ -8,11 +8,14 @@ /* Begin PBXBuildFile section */ A1B2C3D4E5F600000001 /* ForgeApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F600000002 /* ForgeApp.swift */; }; + A1B2C3D4E5F600000019 /* StrapiToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F600000017 /* StrapiToken.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ A1B2C3D4E5F600000002 /* ForgeApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForgeApp.swift; sourceTree = ""; }; A1B2C3D4E5F600000003 /* ForgeApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ForgeApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A1B2C3D4E5F600000015 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A1B2C3D4E5F600000017 /* StrapiToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StrapiToken.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -45,11 +48,21 @@ A1B2C3D4E5F600000006 /* ForgeApp */ = { isa = PBXGroup; children = ( + A1B2C3D4E5F600000018 /* Generated */, A1B2C3D4E5F600000002 /* ForgeApp.swift */, + A1B2C3D4E5F600000015 /* Info.plist */, ); path = ForgeApp; sourceTree = ""; }; + A1B2C3D4E5F600000018 /* Generated */ = { + isa = PBXGroup; + children = ( + A1B2C3D4E5F600000017 /* StrapiToken.swift */, + ); + path = Generated; + sourceTree = ""; + }; A1B2C3D4E5F600000012 /* Package product dependencies */ = { isa = PBXGroup; children = ( @@ -64,6 +77,7 @@ isa = PBXNativeTarget; buildConfigurationList = A1B2C3D4E5F60000000F /* Build configuration list for PBXNativeTarget "ForgeApp" */; buildPhases = ( + A1B2C3D4E5F600000016 /* Generate Strapi token */, A1B2C3D4E5F600000007 /* Sources */, A1B2C3D4E5F600000008 /* Frameworks */, A1B2C3D4E5F600000009 /* Resources */, @@ -116,6 +130,25 @@ }; /* End PBXProject section */ +/* Begin PBXShellScriptBuildPhase section */ + A1B2C3D4E5F600000016 /* Generate Strapi token */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "$(PROJECT_DIR)/../../../apps/cms/.env", + ); + name = "Generate Strapi token"; + outputPaths = ( + "$(PROJECT_DIR)/ForgeApp/Generated/StrapiToken.swift", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "ENV_FILE=\"${PROJECT_DIR}/../../../apps/cms/.env\"\nmkdir -p \"${PROJECT_DIR}/ForgeApp/Generated\"\nOUT=\"${PROJECT_DIR}/ForgeApp/Generated/StrapiToken.swift\"\nif [ -f \"$ENV_FILE\" ]; then\n TOKEN=$(grep '^STRAPI_FULL_ACCESS_TOKEN=' \"$ENV_FILE\" 2>/dev/null | sed 's/^STRAPI_FULL_ACCESS_TOKEN=//' | tr -d '\\n' | sed 's/^ *//;s/ *$//')\nelse\n TOKEN=\"\"\nfi\nif [ -z \"$TOKEN\" ]; then\n echo '// Generated at build time — do not edit\nlet kStrapiFullAccessToken: String? = nil' > \"$OUT\"\nelse\n ESCAPED=$(printf '%s' \"$TOKEN\" | sed 's/\\\\/\\\\\\\\/g; s/\"/\\\\\"/g')\n printf '// Generated at build time — do not edit\nlet kStrapiFullAccessToken: String? = \"%s\"\n' \"$ESCAPED\" > \"$OUT\"\nfi\n"; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXResourcesBuildPhase section */ A1B2C3D4E5F600000009 /* Resources */ = { isa = PBXResourcesBuildPhase; @@ -132,6 +165,7 @@ buildActionMask = 2147483647; files = ( A1B2C3D4E5F600000001 /* ForgeApp.swift in Sources */, + A1B2C3D4E5F600000019 /* StrapiToken.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -202,6 +236,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ForgeApp/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; @@ -226,6 +261,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ForgeApp/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; diff --git a/mobile/ios/App/ForgeApp/ForgeApp.swift b/mobile/ios/App/ForgeApp/ForgeApp.swift index 3c08ea22..4a3fc417 100644 --- a/mobile/ios/App/ForgeApp/ForgeApp.swift +++ b/mobile/ios/App/ForgeApp/ForgeApp.swift @@ -3,9 +3,19 @@ import ForgeMobile @main struct ForgeApp: App { + private static let graphQLURL = URL(string: "http://localhost:1337/graphql")! + var body: some Scene { WindowGroup { - ForgeRootView() + ForgeRootView(contentRepository: makeContentRepository()) } } + + private func makeContentRepository() -> ContentRepository { + let token = ProcessInfo.processInfo.environment["STRAPI_FULL_ACCESS_TOKEN"] + .flatMap { $0.isEmpty ? nil : $0 } + ?? kStrapiFullAccessToken + let client = GraphQLContentClient(endpoint: Self.graphQLURL, bearerToken: token) + return ContentRepository(client: client) + } } diff --git a/mobile/ios/App/ForgeApp/Info.plist b/mobile/ios/App/ForgeApp/Info.plist new file mode 100644 index 00000000..f6e8e65b --- /dev/null +++ b/mobile/ios/App/ForgeApp/Info.plist @@ -0,0 +1,17 @@ + + + + + NSAppTransportSecurity + + NSExceptionDomains + + localhost + + NSExceptionAllowsInsecureHTTPLoads + + + + + + diff --git a/mobile/ios/README.md b/mobile/ios/README.md index ed4a85bb..f872031f 100644 --- a/mobile/ios/README.md +++ b/mobile/ios/README.md @@ -16,6 +16,8 @@ Integrates via **ContentClient**. A GraphQL implementation is provided: **GraphQ App source lives under **App/ForgeApp/**; the **ForgeMobile** library is in **Sources/ForgeMobile**. The app target depends on the local ForgeMobile Swift package (one level up from the project); Xcode resolves it automatically when you open the project. +**Local Strapi with API token:** The app reads `STRAPI_FULL_ACCESS_TOKEN` from `apps/cms/.env` at **build time** (Run Script phase), so when you build and run from **Cursor (SweetPad)** or the command line, no Xcode setup is needed—just ensure `apps/cms/.env` has `STRAPI_FULL_ACCESS_TOKEN` set. If you run from Xcode, you can instead set that variable in **Edit Scheme → Run → Environment Variables**. + ### Command line From this directory (`mobile/ios`). Requires **Xcode 16+** (Swift 6). Point `xcodebuild` at the app project in `App/`: diff --git a/mobile/ios/Sources/ForgeMobile/ContentRepository.swift b/mobile/ios/Sources/ForgeMobile/ContentRepository.swift index 81439984..9f09c345 100644 --- a/mobile/ios/Sources/ForgeMobile/ContentRepository.swift +++ b/mobile/ios/Sources/ForgeMobile/ContentRepository.swift @@ -20,6 +20,6 @@ public final class ContentRepository { } public func fetchHome(locale: String) async throws -> MobileContentItem? { - try await client.getContent(locale: locale, slug: "home") + try await client.getContent(locale: locale, slug: "experience") } } diff --git a/mobile/ios/Sources/ForgeMobile/ForgeRootView.swift b/mobile/ios/Sources/ForgeMobile/ForgeRootView.swift index 408563c1..98f1cde9 100644 --- a/mobile/ios/Sources/ForgeMobile/ForgeRootView.swift +++ b/mobile/ios/Sources/ForgeMobile/ForgeRootView.swift @@ -1,10 +1,58 @@ import SwiftUI public struct ForgeRootView: View { - public init() {} + private let contentRepository: ContentRepository? + + public init(contentRepository: ContentRepository? = nil) { + self.contentRepository = contentRepository + } public var body: some View { - Text("Forge iOS") - .accessibilityLabel("Forge iOS") + if let repo = contentRepository { + GraphQLTestView(repository: repo) + } else { + Text("Forge iOS") + .accessibilityLabel("Forge iOS") + } + } +} + +private struct GraphQLTestView: View { + let repository: ContentRepository + @State private var homeItem: MobileContentItem? + @State private var homeError: String? + @State private var isLoading = true + + var body: some View { + VStack(spacing: 12) { + Text("Forge iOS") + .accessibilityLabel("Forge iOS") + if isLoading { + ProgressView("Loading…") + .accessibilityLabel("Loading content") + } else if let item = homeItem { + Text("Loaded: \(item.title)") + .accessibilityLabel("Loaded title \(item.title)") + } else if let error = homeError { + Text("Error: \(error)") + .foregroundStyle(.red) + .accessibilityLabel("Error \(error)") + } else { + Text("No content") + .accessibilityLabel("No content") + } + } + .padding() + .task { + defer { isLoading = false } + do { + let item = try await repository.fetchHome(locale: "en") + homeItem = item + homeError = nil + } catch { + homeItem = nil + homeError = error.localizedDescription + } + } } } diff --git a/mobile/ios/Sources/ForgeMobile/GraphQLContentClient.swift b/mobile/ios/Sources/ForgeMobile/GraphQLContentClient.swift index 6333407d..a846940e 100644 --- a/mobile/ios/Sources/ForgeMobile/GraphQLContentClient.swift +++ b/mobile/ios/Sources/ForgeMobile/GraphQLContentClient.swift @@ -103,6 +103,14 @@ public final class GraphQLContentClient: ContentClient { } } -public enum GraphQLContentClientError: Error { +public enum GraphQLContentClientError: Error, LocalizedError { case graphQLErrors([GraphQLError]) + + public var errorDescription: String? { + switch self { + case .graphQLErrors(let errors): + let messages = errors.compactMap(\.message) + return messages.isEmpty ? "GraphQL error" : messages.joined(separator: "; ") + } + } } From 42aa770b39eefe0e229d809ad59d0254e4672d84 Mon Sep 17 00:00:00 2001 From: Ur-imazing Date: Thu, 26 Feb 2026 11:56:40 +1300 Subject: [PATCH 12/19] chore(mobile-ios): debug vs release info plists for ATS and app store - Info-Debug.plist: localhost HTTP exception for local Strapi (Debug only) - Info-Release.plist: no ATS override for Archive / App Store Connect - Project: INFOPLIST_FILE per configuration; README documents plist usage Made-with: Cursor --- mobile/ios/App/ForgeApp.xcodeproj/project.pbxproj | 10 ++++++---- .../ios/App/ForgeApp/{Info.plist => Info-Debug.plist} | 0 mobile/ios/App/ForgeApp/Info-Release.plist | 5 +++++ mobile/ios/README.md | 2 ++ 4 files changed, 13 insertions(+), 4 deletions(-) rename mobile/ios/App/ForgeApp/{Info.plist => Info-Debug.plist} (100%) create mode 100644 mobile/ios/App/ForgeApp/Info-Release.plist diff --git a/mobile/ios/App/ForgeApp.xcodeproj/project.pbxproj b/mobile/ios/App/ForgeApp.xcodeproj/project.pbxproj index 17b5eff8..3d70a3e7 100644 --- a/mobile/ios/App/ForgeApp.xcodeproj/project.pbxproj +++ b/mobile/ios/App/ForgeApp.xcodeproj/project.pbxproj @@ -14,7 +14,8 @@ /* Begin PBXFileReference section */ A1B2C3D4E5F600000002 /* ForgeApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForgeApp.swift; sourceTree = ""; }; A1B2C3D4E5F600000003 /* ForgeApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ForgeApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; - A1B2C3D4E5F600000015 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A1B2C3D4E5F60000001A /* Info-Debug.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Info-Debug.plist"; sourceTree = ""; }; + A1B2C3D4E5F60000001B /* Info-Release.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Info-Release.plist"; sourceTree = ""; }; A1B2C3D4E5F600000017 /* StrapiToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StrapiToken.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -50,7 +51,8 @@ children = ( A1B2C3D4E5F600000018 /* Generated */, A1B2C3D4E5F600000002 /* ForgeApp.swift */, - A1B2C3D4E5F600000015 /* Info.plist */, + A1B2C3D4E5F60000001A /* Info-Debug.plist */, + A1B2C3D4E5F60000001B /* Info-Release.plist */, ); path = ForgeApp; sourceTree = ""; @@ -236,7 +238,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = ForgeApp/Info.plist; + INFOPLIST_FILE = ForgeApp/Info-Debug.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; @@ -261,7 +263,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = ForgeApp/Info.plist; + INFOPLIST_FILE = ForgeApp/Info-Release.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; diff --git a/mobile/ios/App/ForgeApp/Info.plist b/mobile/ios/App/ForgeApp/Info-Debug.plist similarity index 100% rename from mobile/ios/App/ForgeApp/Info.plist rename to mobile/ios/App/ForgeApp/Info-Debug.plist diff --git a/mobile/ios/App/ForgeApp/Info-Release.plist b/mobile/ios/App/ForgeApp/Info-Release.plist new file mode 100644 index 00000000..0c67376e --- /dev/null +++ b/mobile/ios/App/ForgeApp/Info-Release.plist @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile/ios/README.md b/mobile/ios/README.md index f872031f..085530cd 100644 --- a/mobile/ios/README.md +++ b/mobile/ios/README.md @@ -18,6 +18,8 @@ App source lives under **App/ForgeApp/**; the **ForgeMobile** library is in **So **Local Strapi with API token:** The app reads `STRAPI_FULL_ACCESS_TOKEN` from `apps/cms/.env` at **build time** (Run Script phase), so when you build and run from **Cursor (SweetPad)** or the command line, no Xcode setup is needed—just ensure `apps/cms/.env` has `STRAPI_FULL_ACCESS_TOKEN` set. If you run from Xcode, you can instead set that variable in **Edit Scheme → Run → Environment Variables**. +**Info plists and App Store:** **Debug** builds use `Info-Debug.plist` (allows HTTP to localhost for local Strapi). **Release** builds (including **Archive** for App Store Connect) use `Info-Release.plist`, which has no ATS exception, so the shipped app is ATS-clean for review. + ### Command line From this directory (`mobile/ios`). Requires **Xcode 16+** (Swift 6). Point `xcodebuild` at the app project in `App/`: From ce8837e16e5ad55953d68351b9e49d3436cef744 Mon Sep 17 00:00:00 2001 From: Ur-imazing Date: Thu, 26 Feb 2026 11:57:57 +1300 Subject: [PATCH 13/19] chore(mobile-ios): exclude generated code from swiftlint - Sources/ForgeMobile/Generated (Apollo) - App/ForgeApp/Generated (StrapiToken.swift) so pnpm lint passes for @forge/mobile-ios Made-with: Cursor --- mobile/ios/.swiftlint.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mobile/ios/.swiftlint.yml b/mobile/ios/.swiftlint.yml index 7be817fa..f493fa6e 100644 --- a/mobile/ios/.swiftlint.yml +++ b/mobile/ios/.swiftlint.yml @@ -1,2 +1,6 @@ excluded: - .build + # Apollo codegen output; do not edit + - Sources/ForgeMobile/Generated + # Build-generated token from apps/cms/.env + - App/ForgeApp/Generated From cae66e81bd13e407b5d230cef7bb32f62b8cca70 Mon Sep 17 00:00:00 2001 From: Ur-imazing Date: Thu, 26 Feb 2026 12:04:09 +1300 Subject: [PATCH 14/19] fix(mobile-ios): address CodeRabbit review - GraphQLContentClient: only set Authorization when token non-empty (trim whitespace) - GraphQLContentClient: throw graphQLErrors when errors present even if data exists - GraphQLContentClient: use experience.documentId directly (ID is String) - GetWatchExperience.graphql: remove redundant __typename (Apollo injects) - ForgeRootView: show GraphQLTestView only in DEBUG builds - ForgeApp: document localhost URL as default for local dev - Run Script: omit token in Release so app binary never contains credential Made-with: Cursor --- mobile/ios/App/ForgeApp.xcodeproj/project.pbxproj | 3 +-- mobile/ios/App/ForgeApp/ForgeApp.swift | 1 + .../ios/GraphQL/Operations/GetWatchExperience.graphql | 1 - mobile/ios/Sources/ForgeMobile/ForgeRootView.swift | 5 +++++ .../Sources/ForgeMobile/GraphQLContentClient.swift | 11 ++++++----- 5 files changed, 13 insertions(+), 8 deletions(-) diff --git a/mobile/ios/App/ForgeApp.xcodeproj/project.pbxproj b/mobile/ios/App/ForgeApp.xcodeproj/project.pbxproj index 3d70a3e7..e44b890f 100644 --- a/mobile/ios/App/ForgeApp.xcodeproj/project.pbxproj +++ b/mobile/ios/App/ForgeApp.xcodeproj/project.pbxproj @@ -139,7 +139,6 @@ files = ( ); inputPaths = ( - "$(PROJECT_DIR)/../../../apps/cms/.env", ); name = "Generate Strapi token"; outputPaths = ( @@ -147,7 +146,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "ENV_FILE=\"${PROJECT_DIR}/../../../apps/cms/.env\"\nmkdir -p \"${PROJECT_DIR}/ForgeApp/Generated\"\nOUT=\"${PROJECT_DIR}/ForgeApp/Generated/StrapiToken.swift\"\nif [ -f \"$ENV_FILE\" ]; then\n TOKEN=$(grep '^STRAPI_FULL_ACCESS_TOKEN=' \"$ENV_FILE\" 2>/dev/null | sed 's/^STRAPI_FULL_ACCESS_TOKEN=//' | tr -d '\\n' | sed 's/^ *//;s/ *$//')\nelse\n TOKEN=\"\"\nfi\nif [ -z \"$TOKEN\" ]; then\n echo '// Generated at build time — do not edit\nlet kStrapiFullAccessToken: String? = nil' > \"$OUT\"\nelse\n ESCAPED=$(printf '%s' \"$TOKEN\" | sed 's/\\\\/\\\\\\\\/g; s/\"/\\\\\"/g')\n printf '// Generated at build time — do not edit\nlet kStrapiFullAccessToken: String? = \"%s\"\n' \"$ESCAPED\" > \"$OUT\"\nfi\n"; + shellScript = "mkdir -p \"${PROJECT_DIR}/ForgeApp/Generated\"\nOUT=\"${PROJECT_DIR}/ForgeApp/Generated/StrapiToken.swift\"\nif [ \"${CONFIGURATION}\" = \"Release\" ]; then\n TOKEN=\"\"\nelse\n ENV_FILE=\"${PROJECT_DIR}/../../../apps/cms/.env\"\n if [ -f \"$ENV_FILE\" ]; then\n TOKEN=$(grep '^STRAPI_FULL_ACCESS_TOKEN=' \"$ENV_FILE\" 2>/dev/null | sed 's/^STRAPI_FULL_ACCESS_TOKEN=//' | tr -d '\\n' | sed 's/^ *//;s/ *$//')\n else\n TOKEN=\"\"\n fi\nfi\nif [ -z \"$TOKEN\" ]; then\n echo '// Generated at build time — do not edit\nlet kStrapiFullAccessToken: String? = nil' > \"$OUT\"\nelse\n ESCAPED=$(printf '%s' \"$TOKEN\" | sed 's/\\\\/\\\\\\\\/g; s/\"/\\\\\"/g')\n printf '// Generated at build time — do not edit\nlet kStrapiFullAccessToken: String? = \"%s\"\n' \"$ESCAPED\" > \"$OUT\"\nfi\n"; }; /* End PBXShellScriptBuildPhase section */ diff --git a/mobile/ios/App/ForgeApp/ForgeApp.swift b/mobile/ios/App/ForgeApp/ForgeApp.swift index 4a3fc417..a1795390 100644 --- a/mobile/ios/App/ForgeApp/ForgeApp.swift +++ b/mobile/ios/App/ForgeApp/ForgeApp.swift @@ -3,6 +3,7 @@ import ForgeMobile @main struct ForgeApp: App { + /// Default for local dev (simulator + Strapi on host). Production should use a configured endpoint (e.g. Info.plist or build config). private static let graphQLURL = URL(string: "http://localhost:1337/graphql")! var body: some Scene { diff --git a/mobile/ios/GraphQL/Operations/GetWatchExperience.graphql b/mobile/ios/GraphQL/Operations/GetWatchExperience.graphql index ef39b3bd..a10bf80a 100644 --- a/mobile/ios/GraphQL/Operations/GetWatchExperience.graphql +++ b/mobile/ios/GraphQL/Operations/GetWatchExperience.graphql @@ -7,7 +7,6 @@ query GetWatchExperience( slug publishedAt sections { - __typename ... on ComponentSectionsMediaCollection { id title diff --git a/mobile/ios/Sources/ForgeMobile/ForgeRootView.swift b/mobile/ios/Sources/ForgeMobile/ForgeRootView.swift index 98f1cde9..6b0f84ab 100644 --- a/mobile/ios/Sources/ForgeMobile/ForgeRootView.swift +++ b/mobile/ios/Sources/ForgeMobile/ForgeRootView.swift @@ -8,12 +8,17 @@ public struct ForgeRootView: View { } public var body: some View { + #if DEBUG if let repo = contentRepository { GraphQLTestView(repository: repo) } else { Text("Forge iOS") .accessibilityLabel("Forge iOS") } + #else + Text("Forge iOS") + .accessibilityLabel("Forge iOS") + #endif } } diff --git a/mobile/ios/Sources/ForgeMobile/GraphQLContentClient.swift b/mobile/ios/Sources/ForgeMobile/GraphQLContentClient.swift index a846940e..e893c0d6 100644 --- a/mobile/ios/Sources/ForgeMobile/GraphQLContentClient.swift +++ b/mobile/ios/Sources/ForgeMobile/GraphQLContentClient.swift @@ -15,7 +15,8 @@ public final class GraphQLContentClient: ContentClient { /// Creates a client that talks to the given endpoint with optional bearer auth. public convenience init(endpoint: URL, bearerToken: String? = nil) { let store = ApolloStore(cache: InMemoryNormalizedCache()) - let headers: [String: String] = if let token = bearerToken { + let trimmedToken = bearerToken?.trimmingCharacters(in: .whitespacesAndNewlines) + let headers: [String: String] = if let token = trimmedToken, !token.isEmpty { ["Authorization": "Bearer \(token)"] } else { [:] @@ -47,10 +48,10 @@ public final class GraphQLContentClient: ContentClient { } switch result { case .success(let graphQLResult): + if let errors = graphQLResult.errors, !errors.isEmpty { + throw GraphQLContentClientError.graphQLErrors(errors) + } guard let data = graphQLResult.data else { - if let errors = graphQLResult.errors, !errors.isEmpty { - throw GraphQLContentClientError.graphQLErrors(errors) - } return nil } guard let first = data.experiences.compactMap({ $0 }).first else { @@ -70,7 +71,7 @@ public final class GraphQLContentClient: ContentClient { let state = experience.publishedAt != nil ? "published" : "draft" // body: Experience has no root-level body in the schema; not requested in the query. Leave empty. return MobileContentItem( - id: String(experience.documentId), + id: experience.documentId, slug: experience.slug, locale: locale, title: title, From 383815e38e00cfca7975fb5999d0d79abe3c9f67 Mon Sep 17 00:00:00 2001 From: Ur-imazing Date: Thu, 26 Feb 2026 12:16:00 +1300 Subject: [PATCH 15/19] =?UTF-8?q?fix(mobile-ios):=20address=20CodeRabbit?= =?UTF-8?q?=20=E2=80=94=20no=20token=20in=20binary,=20GraphQL=20URL=20from?= =?UTF-8?q?=20plist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Token phase: only emit nil stub; never read apps/cms/.env (Critical) - ForgeApp: read GraphQLEndpoint from Info.plist; Debug fallback, Release requires value - GraphQLContentClient: simplify withCheckedContinuation (nitpick) Made-with: Cursor --- .../ios/App/ForgeApp.xcodeproj/project.pbxproj | 2 +- mobile/ios/App/ForgeApp/ForgeApp.swift | 16 ++++++++++++++-- mobile/ios/App/ForgeApp/Info-Debug.plist | 2 ++ mobile/ios/App/ForgeApp/Info-Release.plist | 5 ++++- .../ForgeMobile/GraphQLContentClient.swift | 5 +---- 5 files changed, 22 insertions(+), 8 deletions(-) diff --git a/mobile/ios/App/ForgeApp.xcodeproj/project.pbxproj b/mobile/ios/App/ForgeApp.xcodeproj/project.pbxproj index e44b890f..ec6ac902 100644 --- a/mobile/ios/App/ForgeApp.xcodeproj/project.pbxproj +++ b/mobile/ios/App/ForgeApp.xcodeproj/project.pbxproj @@ -146,7 +146,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "mkdir -p \"${PROJECT_DIR}/ForgeApp/Generated\"\nOUT=\"${PROJECT_DIR}/ForgeApp/Generated/StrapiToken.swift\"\nif [ \"${CONFIGURATION}\" = \"Release\" ]; then\n TOKEN=\"\"\nelse\n ENV_FILE=\"${PROJECT_DIR}/../../../apps/cms/.env\"\n if [ -f \"$ENV_FILE\" ]; then\n TOKEN=$(grep '^STRAPI_FULL_ACCESS_TOKEN=' \"$ENV_FILE\" 2>/dev/null | sed 's/^STRAPI_FULL_ACCESS_TOKEN=//' | tr -d '\\n' | sed 's/^ *//;s/ *$//')\n else\n TOKEN=\"\"\n fi\nfi\nif [ -z \"$TOKEN\" ]; then\n echo '// Generated at build time — do not edit\nlet kStrapiFullAccessToken: String? = nil' > \"$OUT\"\nelse\n ESCAPED=$(printf '%s' \"$TOKEN\" | sed 's/\\\\/\\\\\\\\/g; s/\"/\\\\\"/g')\n printf '// Generated at build time — do not edit\nlet kStrapiFullAccessToken: String? = \"%s\"\n' \"$ESCAPED\" > \"$OUT\"\nfi\n"; + shellScript = "mkdir -p \"${PROJECT_DIR}/ForgeApp/Generated\"\nOUT=\"${PROJECT_DIR}/ForgeApp/Generated/StrapiToken.swift\"\necho '// Generated at build time — do not edit. Token is supplied at runtime via STRAPI_FULL_ACCESS_TOKEN env.' > \"$OUT\"\necho 'let kStrapiFullAccessToken: String? = nil' >> \"$OUT\"\n"; }; /* End PBXShellScriptBuildPhase section */ diff --git a/mobile/ios/App/ForgeApp/ForgeApp.swift b/mobile/ios/App/ForgeApp/ForgeApp.swift index a1795390..dad62251 100644 --- a/mobile/ios/App/ForgeApp/ForgeApp.swift +++ b/mobile/ios/App/ForgeApp/ForgeApp.swift @@ -3,8 +3,20 @@ import ForgeMobile @main struct ForgeApp: App { - /// Default for local dev (simulator + Strapi on host). Production should use a configured endpoint (e.g. Info.plist or build config). - private static let graphQLURL = URL(string: "http://localhost:1337/graphql")! + /// GraphQL endpoint from Info.plist (GraphQLEndpoint). Debug falls back to localhost; Release requires a valid value. + private static var graphQLURL: URL { + let key = "GraphQLEndpoint" + let raw = Bundle.main.object(forInfoDictionaryKey: key) as? String + let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) + if let s = trimmed, !s.isEmpty, let url = URL(string: s) { + return url + } + #if DEBUG + return URL(string: "http://localhost:1337/graphql")! + #else + fatalError("\(key) must be set in Info-Release.plist for production builds.") + #endif + } var body: some Scene { WindowGroup { diff --git a/mobile/ios/App/ForgeApp/Info-Debug.plist b/mobile/ios/App/ForgeApp/Info-Debug.plist index f6e8e65b..9edff843 100644 --- a/mobile/ios/App/ForgeApp/Info-Debug.plist +++ b/mobile/ios/App/ForgeApp/Info-Debug.plist @@ -2,6 +2,8 @@ + GraphQLEndpoint + http://localhost:1337/graphql NSAppTransportSecurity NSExceptionDomains diff --git a/mobile/ios/App/ForgeApp/Info-Release.plist b/mobile/ios/App/ForgeApp/Info-Release.plist index 0c67376e..73eab282 100644 --- a/mobile/ios/App/ForgeApp/Info-Release.plist +++ b/mobile/ios/App/ForgeApp/Info-Release.plist @@ -1,5 +1,8 @@ - + + GraphQLEndpoint + + diff --git a/mobile/ios/Sources/ForgeMobile/GraphQLContentClient.swift b/mobile/ios/Sources/ForgeMobile/GraphQLContentClient.swift index e893c0d6..67455239 100644 --- a/mobile/ios/Sources/ForgeMobile/GraphQLContentClient.swift +++ b/mobile/ios/Sources/ForgeMobile/GraphQLContentClient.swift @@ -38,10 +38,7 @@ public final class GraphQLContentClient: ContentClient { locale: locale, filters: filters ) - typealias Continuation = CheckedContinuation< - Result, Error>, Never - > - let result = await withCheckedContinuation { (continuation: Continuation) in + let result = await withCheckedContinuation { continuation in apollo.fetch(query: query, cachePolicy: .fetchIgnoringCacheData) { result in continuation.resume(returning: result) } From 06aa284df62d2c64f95bcbf7a075ad7514e15083 Mon Sep 17 00:00:00 2001 From: Ur-imazing Date: Thu, 26 Feb 2026 12:34:06 +1300 Subject: [PATCH 16/19] fix(mobile-ios): restore Debug-only token from apps/cms/.env for Cursor runs - Build phase reads STRAPI_FULL_ACCESS_TOKEN from apps/cms/.env in Debug only - Release always emits nil so shipped binary has no token - README: document token source and Forbidden access troubleshooting Made-with: Cursor --- mobile/ios/App/ForgeApp.xcodeproj/project.pbxproj | 2 +- mobile/ios/README.md | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/mobile/ios/App/ForgeApp.xcodeproj/project.pbxproj b/mobile/ios/App/ForgeApp.xcodeproj/project.pbxproj index ec6ac902..1154b0dd 100644 --- a/mobile/ios/App/ForgeApp.xcodeproj/project.pbxproj +++ b/mobile/ios/App/ForgeApp.xcodeproj/project.pbxproj @@ -146,7 +146,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "mkdir -p \"${PROJECT_DIR}/ForgeApp/Generated\"\nOUT=\"${PROJECT_DIR}/ForgeApp/Generated/StrapiToken.swift\"\necho '// Generated at build time — do not edit. Token is supplied at runtime via STRAPI_FULL_ACCESS_TOKEN env.' > \"$OUT\"\necho 'let kStrapiFullAccessToken: String? = nil' >> \"$OUT\"\n"; + shellScript = "mkdir -p \"${PROJECT_DIR}/ForgeApp/Generated\"\nOUT=\"${PROJECT_DIR}/ForgeApp/Generated/StrapiToken.swift\"\nif [ \"${CONFIGURATION}\" = \"Release\" ]; then\n echo '// Generated at build time — do not edit. Release: no token in binary.' > \"$OUT\"\n echo 'let kStrapiFullAccessToken: String? = nil' >> \"$OUT\"\nelse\n ENV_FILE=\"${PROJECT_DIR}/../../../apps/cms/.env\"\n if [ -f \"$ENV_FILE\" ]; then\n TOKEN=$(grep '^STRAPI_FULL_ACCESS_TOKEN=' \"$ENV_FILE\" 2>/dev/null | sed 's/^STRAPI_FULL_ACCESS_TOKEN=//' | tr -d '\\n' | sed 's/^ *//;s/ *$//')\n else\n TOKEN=\"\"\n fi\n if [ -z \"$TOKEN\" ]; then\n echo '// Generated at build time — do not edit. Debug: set STRAPI_FULL_ACCESS_TOKEN in apps/cms/.env or scheme.' > \"$OUT\"\n echo 'let kStrapiFullAccessToken: String? = nil' >> \"$OUT\"\n else\n ESCAPED=$(printf '%s' \"$TOKEN\" | sed 's/\\\\/\\\\\\\\/g; s/\"/\\\\\"/g')\n printf '// Generated at build time — do not edit. Debug only.\nlet kStrapiFullAccessToken: String? = \"%s\"\n' \"$ESCAPED\" > \"$OUT\"\n fi\nfi\n"; }; /* End PBXShellScriptBuildPhase section */ diff --git a/mobile/ios/README.md b/mobile/ios/README.md index 085530cd..e9b75826 100644 --- a/mobile/ios/README.md +++ b/mobile/ios/README.md @@ -16,7 +16,14 @@ Integrates via **ContentClient**. A GraphQL implementation is provided: **GraphQ App source lives under **App/ForgeApp/**; the **ForgeMobile** library is in **Sources/ForgeMobile**. The app target depends on the local ForgeMobile Swift package (one level up from the project); Xcode resolves it automatically when you open the project. -**Local Strapi with API token:** The app reads `STRAPI_FULL_ACCESS_TOKEN` from `apps/cms/.env` at **build time** (Run Script phase), so when you build and run from **Cursor (SweetPad)** or the command line, no Xcode setup is needed—just ensure `apps/cms/.env` has `STRAPI_FULL_ACCESS_TOKEN` set. If you run from Xcode, you can instead set that variable in **Edit Scheme → Run → Environment Variables**. +**Local Strapi with API token:** + +- **Debug (e.g. run from Cursor/SweetPad):** A build-phase script reads `STRAPI_FULL_ACCESS_TOKEN` from `apps/cms/.env` and attaches it to GraphQL requests. Ensure `apps/cms/.env` has that variable set; no Xcode scheme setup needed. +- **Release:** The script never embeds a token (always `nil`), so the shipped binary stays safe. Use a backend or runtime token for production. + +You can still override by setting the **environment variable** `STRAPI_FULL_ACCESS_TOKEN` in the run scheme (Edit Scheme → Run → Environment Variables); the app uses env first, then the generated value. + +If you see **"Error: Forbidden access"**, the request is reaching Strapi without a valid token—for Debug from Cursor, add `STRAPI_FULL_ACCESS_TOKEN` to `apps/cms/.env` and rebuild. **Info plists and App Store:** **Debug** builds use `Info-Debug.plist` (allows HTTP to localhost for local Strapi). **Release** builds (including **Archive** for App Store Connect) use `Info-Release.plist`, which has no ATS exception, so the shipped app is ATS-clean for review. From d814e2d94a8e387b2237ab21dd983ed50d083159 Mon Sep 17 00:00:00 2001 From: Ur-imazing Date: Thu, 26 Feb 2026 13:54:07 +1300 Subject: [PATCH 17/19] refactor(mobile-ios): move content wiring out of ForgeApp into factory Made-with: Cursor --- .../App/ForgeApp.xcodeproj/project.pbxproj | 4 +++ .../AppContentRepositoryFactory.swift | 28 +++++++++++++++++++ mobile/ios/App/ForgeApp/ForgeApp.swift | 25 +---------------- 3 files changed, 33 insertions(+), 24 deletions(-) create mode 100644 mobile/ios/App/ForgeApp/AppContentRepositoryFactory.swift diff --git a/mobile/ios/App/ForgeApp.xcodeproj/project.pbxproj b/mobile/ios/App/ForgeApp.xcodeproj/project.pbxproj index 1154b0dd..6f961e26 100644 --- a/mobile/ios/App/ForgeApp.xcodeproj/project.pbxproj +++ b/mobile/ios/App/ForgeApp.xcodeproj/project.pbxproj @@ -8,10 +8,12 @@ /* Begin PBXBuildFile section */ A1B2C3D4E5F600000001 /* ForgeApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F600000002 /* ForgeApp.swift */; }; + A1B2C3D4E5F60000001C /* AppContentRepositoryFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60000001D /* AppContentRepositoryFactory.swift */; }; A1B2C3D4E5F600000019 /* StrapiToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F600000017 /* StrapiToken.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + A1B2C3D4E5F60000001D /* AppContentRepositoryFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppContentRepositoryFactory.swift; sourceTree = ""; }; A1B2C3D4E5F600000002 /* ForgeApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForgeApp.swift; sourceTree = ""; }; A1B2C3D4E5F600000003 /* ForgeApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ForgeApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; A1B2C3D4E5F60000001A /* Info-Debug.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Info-Debug.plist"; sourceTree = ""; }; @@ -49,6 +51,7 @@ A1B2C3D4E5F600000006 /* ForgeApp */ = { isa = PBXGroup; children = ( + A1B2C3D4E5F60000001D /* AppContentRepositoryFactory.swift */, A1B2C3D4E5F600000018 /* Generated */, A1B2C3D4E5F600000002 /* ForgeApp.swift */, A1B2C3D4E5F60000001A /* Info-Debug.plist */, @@ -165,6 +168,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A1B2C3D4E5F60000001C /* AppContentRepositoryFactory.swift in Sources */, A1B2C3D4E5F600000001 /* ForgeApp.swift in Sources */, A1B2C3D4E5F600000019 /* StrapiToken.swift in Sources */, ); diff --git a/mobile/ios/App/ForgeApp/AppContentRepositoryFactory.swift b/mobile/ios/App/ForgeApp/AppContentRepositoryFactory.swift new file mode 100644 index 00000000..1e71ec03 --- /dev/null +++ b/mobile/ios/App/ForgeApp/AppContentRepositoryFactory.swift @@ -0,0 +1,28 @@ +import Foundation +import ForgeMobile + +/// Builds the app's `ContentRepository` using GraphQL endpoint from Info.plist and optional bearer token. +enum AppContentRepositoryFactory { + /// GraphQL endpoint from Info.plist (GraphQLEndpoint). Debug falls back to localhost; Release requires a valid value. + private static var graphQLURL: URL { + let key = "GraphQLEndpoint" + let raw = Bundle.main.object(forInfoDictionaryKey: key) as? String + let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) + if let endpointString = trimmed, !endpointString.isEmpty, let url = URL(string: endpointString) { + return url + } + #if DEBUG + return URL(string: "http://localhost:1337/graphql")! + #else + fatalError("\(key) must be set in Info-Release.plist for production builds.") + #endif + } + + static func makeContentRepository() -> ContentRepository { + let token = ProcessInfo.processInfo.environment["STRAPI_FULL_ACCESS_TOKEN"] + .flatMap { $0.isEmpty ? nil : $0 } + ?? kStrapiFullAccessToken + let client = GraphQLContentClient(endpoint: graphQLURL, bearerToken: token) + return ContentRepository(client: client) + } +} diff --git a/mobile/ios/App/ForgeApp/ForgeApp.swift b/mobile/ios/App/ForgeApp/ForgeApp.swift index dad62251..67437c99 100644 --- a/mobile/ios/App/ForgeApp/ForgeApp.swift +++ b/mobile/ios/App/ForgeApp/ForgeApp.swift @@ -3,32 +3,9 @@ import ForgeMobile @main struct ForgeApp: App { - /// GraphQL endpoint from Info.plist (GraphQLEndpoint). Debug falls back to localhost; Release requires a valid value. - private static var graphQLURL: URL { - let key = "GraphQLEndpoint" - let raw = Bundle.main.object(forInfoDictionaryKey: key) as? String - let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) - if let s = trimmed, !s.isEmpty, let url = URL(string: s) { - return url - } - #if DEBUG - return URL(string: "http://localhost:1337/graphql")! - #else - fatalError("\(key) must be set in Info-Release.plist for production builds.") - #endif - } - var body: some Scene { WindowGroup { - ForgeRootView(contentRepository: makeContentRepository()) + ForgeRootView(contentRepository: AppContentRepositoryFactory.makeContentRepository()) } } - - private func makeContentRepository() -> ContentRepository { - let token = ProcessInfo.processInfo.environment["STRAPI_FULL_ACCESS_TOKEN"] - .flatMap { $0.isEmpty ? nil : $0 } - ?? kStrapiFullAccessToken - let client = GraphQLContentClient(endpoint: Self.graphQLURL, bearerToken: token) - return ContentRepository(client: client) - } } From f66c0047aec11005a66235768772f33f25815e30 Mon Sep 17 00:00:00 2001 From: Ur-imazing Date: Thu, 26 Feb 2026 14:07:06 +1300 Subject: [PATCH 18/19] feat(mobile-ios): enforce MVVM with WatchHomeViewModel and Data/ViewModels/Views structure Made-with: Cursor --- .../{ => Data}/ContentRepository.swift | 0 .../{ => Data}/GraphQLContentClient.swift | 0 .../ViewModels/WatchHomeViewModel.swift | 31 +++++++++++++++++++ .../{ => Views}/ForgeRootView.swift | 27 ++++++---------- 4 files changed, 40 insertions(+), 18 deletions(-) rename mobile/ios/Sources/ForgeMobile/{ => Data}/ContentRepository.swift (100%) rename mobile/ios/Sources/ForgeMobile/{ => Data}/GraphQLContentClient.swift (100%) create mode 100644 mobile/ios/Sources/ForgeMobile/ViewModels/WatchHomeViewModel.swift rename mobile/ios/Sources/ForgeMobile/{ => Views}/ForgeRootView.swift (63%) diff --git a/mobile/ios/Sources/ForgeMobile/ContentRepository.swift b/mobile/ios/Sources/ForgeMobile/Data/ContentRepository.swift similarity index 100% rename from mobile/ios/Sources/ForgeMobile/ContentRepository.swift rename to mobile/ios/Sources/ForgeMobile/Data/ContentRepository.swift diff --git a/mobile/ios/Sources/ForgeMobile/GraphQLContentClient.swift b/mobile/ios/Sources/ForgeMobile/Data/GraphQLContentClient.swift similarity index 100% rename from mobile/ios/Sources/ForgeMobile/GraphQLContentClient.swift rename to mobile/ios/Sources/ForgeMobile/Data/GraphQLContentClient.swift diff --git a/mobile/ios/Sources/ForgeMobile/ViewModels/WatchHomeViewModel.swift b/mobile/ios/Sources/ForgeMobile/ViewModels/WatchHomeViewModel.swift new file mode 100644 index 00000000..59e53301 --- /dev/null +++ b/mobile/ios/Sources/ForgeMobile/ViewModels/WatchHomeViewModel.swift @@ -0,0 +1,31 @@ +import Foundation + +/// ViewModel for the watch/home screen. Owns repository access and exposes loading state and content. +@Observable +public final class WatchHomeViewModel { + public private(set) var isLoading = false + public private(set) var homeItem: MobileContentItem? + public private(set) var homeError: String? + + private let repository: ContentRepository + + public init(repository: ContentRepository) { + self.repository = repository + } + + /// Loads home content for the given locale. Updates `isLoading`, `homeItem`, and `homeError`. + public func load(locale: String = "en") async { + isLoading = true + homeError = nil + homeItem = nil + defer { isLoading = false } + do { + let item = try await repository.fetchHome(locale: locale) + homeItem = item + homeError = nil + } catch { + homeItem = nil + homeError = error.localizedDescription + } + } +} diff --git a/mobile/ios/Sources/ForgeMobile/ForgeRootView.swift b/mobile/ios/Sources/ForgeMobile/Views/ForgeRootView.swift similarity index 63% rename from mobile/ios/Sources/ForgeMobile/ForgeRootView.swift rename to mobile/ios/Sources/ForgeMobile/Views/ForgeRootView.swift index 6b0f84ab..e13cd022 100644 --- a/mobile/ios/Sources/ForgeMobile/ForgeRootView.swift +++ b/mobile/ios/Sources/ForgeMobile/Views/ForgeRootView.swift @@ -2,15 +2,17 @@ import SwiftUI public struct ForgeRootView: View { private let contentRepository: ContentRepository? + @State private var viewModel: WatchHomeViewModel? public init(contentRepository: ContentRepository? = nil) { self.contentRepository = contentRepository + _viewModel = State(initialValue: contentRepository.map { WatchHomeViewModel(repository: $0) }) } public var body: some View { #if DEBUG - if let repo = contentRepository { - GraphQLTestView(repository: repo) + if let viewModel = viewModel { + GraphQLTestView(viewModel: viewModel) } else { Text("Forge iOS") .accessibilityLabel("Forge iOS") @@ -23,22 +25,19 @@ public struct ForgeRootView: View { } private struct GraphQLTestView: View { - let repository: ContentRepository - @State private var homeItem: MobileContentItem? - @State private var homeError: String? - @State private var isLoading = true + let viewModel: WatchHomeViewModel var body: some View { VStack(spacing: 12) { Text("Forge iOS") .accessibilityLabel("Forge iOS") - if isLoading { + if viewModel.isLoading { ProgressView("Loading…") .accessibilityLabel("Loading content") - } else if let item = homeItem { + } else if let item = viewModel.homeItem { Text("Loaded: \(item.title)") .accessibilityLabel("Loaded title \(item.title)") - } else if let error = homeError { + } else if let error = viewModel.homeError { Text("Error: \(error)") .foregroundStyle(.red) .accessibilityLabel("Error \(error)") @@ -49,15 +48,7 @@ private struct GraphQLTestView: View { } .padding() .task { - defer { isLoading = false } - do { - let item = try await repository.fetchHome(locale: "en") - homeItem = item - homeError = nil - } catch { - homeItem = nil - homeError = error.localizedDescription - } + await viewModel.load(locale: "en") } } } From 5b809aaf5cba1762d41e57bf5ea8ea48dd222d78 Mon Sep 17 00:00:00 2001 From: Ur-imazing Date: Thu, 26 Feb 2026 14:17:32 +1300 Subject: [PATCH 19/19] docs(mobile-ios): document MVVM and ForgeMobile layout in README Made-with: Cursor --- mobile/ios/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mobile/ios/README.md b/mobile/ios/README.md index e9b75826..4c4aad6f 100644 --- a/mobile/ios/README.md +++ b/mobile/ios/README.md @@ -2,7 +2,9 @@ Native SwiftUI app. Outside Turborepo graph. -Integrates via **ContentClient**. A GraphQL implementation is provided: **GraphQLContentClient** (endpoint URL + optional bearer token). Construct it with your CMS GraphQL URL (e.g. dev/stage/prod) and pass it to `ContentRepository(client:)`. +Integrates via **ContentClient**. A GraphQL implementation is provided: **GraphQLContentClient** (endpoint URL + optional bearer token). Construct it with your CMS GraphQL URL (e.g. dev/stage/prod) and pass it to `ContentRepository(client:)`. The app uses **MVVM**: views depend only on **ViewModels**; ViewModels own the repository and expose state and actions (e.g. `WatchHomeViewModel` and `load(locale:)`). + +**ForgeMobile library layout** (under `Sources/ForgeMobile/`): **Data/** — `ContentRepository`, `GraphQLContentClient`, `ContentClient`, `MobileContentItem`; **ViewModels/** — screen ViewModels (e.g. `WatchHomeViewModel`); **Views/** — SwiftUI views (e.g. `ForgeRootView`); **Generated/** — Apollo-generated types and operations (do not edit). ## Building and running