| name | infomaniak-swiftui |
| description | Infomaniak house conventions for modular SwiftUI apps built with Tuist (multi-module App/Core/CoreUI/Features/Resources architecture, DI via InfomaniakDI, Realm via Transactionable, InfomaniakCoreUI paddings, view declaration order, view naming conventions, sheet/modal presentation patterns, Swift 6 concurrency). Use when creating, structuring, scaffolding or reviewing SwiftUI/Tuist code in Infomaniak iOS repos (authenticator, SwissTransfer, kMail…). |
Infomaniak — SwiftUI + Tuist house style
This skill encodes the architecture and code conventions of Infomaniak's iOS apps. The
architecture reference is ios-authenticator, confirmed by ios-SwissTransfer (identical
Tuist setup). ios-kMail is a reference only for the Realm layer; do not copy its
internal organization (Mail/Views/..., MailCore/Cache), which is historical tech debt.
Priority on divergence: authenticator > SwissTransfer > (kMail for Realm only).
Generate code that blends into the existing module: match the comment density, naming and
idioms of the neighbouring file.
1. Modular architecture
A project is an app split into Tuist modules, named after the project name (<App> below,
e.g. Authenticator, SwissTransfer):
<App>/ # App target (product .app)
├── Sources/
│ ├── <App>App.swift # @main : struct App
│ ├── AppDelegate.swift # UIApplicationDelegate (if needed)
│ └── <App>AppTargetAssembly.swift # TargetAssembly subclass (app target DI)
└── Resources/ # Info.plist, entitlements, app Assets, Localizable, AppIcon
<App>Core/ # Business logic — NO UI code
├── TargetAssembly.swift # DI registration (Factory / SimpleResolver)
├── AccountManager.swift, UserSession.swift, Constants.swift, ...
└── Sentry/ # crash reporting integration
<App>CoreUI/ # UI shared across features
├── Components/ # reusable views
├── Extensions/ # Color+, Font+, View+
├── Modifiers/ # custom ViewModifier
├── Shared State/ # shared-state ObservableObject (RootViewState, MainViewState)
├── UIModels/ # UI-tailored models (UIAccount, ...)
└── Preview/ # PreviewHelper, sample data
<App>Features/<FeatureName>/ # 1 folder = 1 feature = 1 Tuist target
├── <FeatureName>.swift # feature root view
└── ... feature subviews / cells / modifiers
<App>Resources/ # Shared assets
├── Colors.xcassets, Images.xcassets
└── Localizable/<lang>.lproj
Tuist/
├── Package.swift # SPM declarations
└── ProjectDescriptionHelpers/
├── Constants.swift # project name, bundleId, versions, settings, scripts
├── Feature.swift # the `Feature` type (generates one target per feature)
└── ExtensionTarget.swift # helpers for app extensions
Project.swift # declares the Feature list + app/Core/CoreUI/Resources targets
Tuist.swift # `let tuist = Tuist(project: .tuist())`
mise.toml # tuist/swiftlint/swiftformat/... tool versions
.swift-version # 6.0
.swiftformat, .swiftlint.yml, file-header-template.txt
Placement rules:
- A view specific to one feature →
<App>Features/<Feature>/. If it splits into subviews,
create a dedicated subfolder.
- A view/component reused by ≥2 features →
<App>CoreUI/Components/.
- Business logic, a manager, a service →
<App>Core/ (never any UI).
- State shared between views (
ObservableObject) → <App>CoreUI/Shared State/.
- A model tailored for display →
<App>CoreUI/UIModels/ (prefix UI…).
2. Tuist + mise setup (initializing a cloned repo)
Always in this order:
mise install
tuist install
tuist generate
Never commit the generated .xcodeproj: it is regenerated by tuist generate.
After any dependency or target-structure change, re-run tuist generate.
3. Declaring a module / feature in Tuist
A feature is declared via the Feature type (in ProjectDescriptionHelpers). By default it
depends on <App>Core + <App>CoreUI; everything else goes in additionalDependencies.
let settingsView = Feature(
name: "SettingsView",
additionalDependencies: [
TargetDependency.target(name: "\(Constants.projectName)Resources"),
TargetDependency.external(name: "InfomaniakPrivacyManagement"),
TargetDependency.external(name: "AppLock")
]
)
let mainView = Feature(
name: "MainView",
additionalDependencies: [
settingsView,
accountsView,
TargetDependency.target(name: "\(Constants.projectName)Resources")
]
)
let project = Project(
name: Constants.projectName,
targets: [mainView, settingsView, accountsView].asTargets + [ ],
fileHeaderTemplate: .file("file-header-template.txt")
)
- The generated target is named
<App><FeatureName>, bundleId <base>.features.<name>,
sources <App>Features/<name>/**.
- Product type is environment-driven (
Constants.productTypeBasedOnEnv → .framework by
default, .staticLibrary possible) — do not hardcode .framework.
- Always reuse
Constants.projectName, Constants.baseSettings,
Constants.deploymentTarget rather than literals.
- Every source file carries the GPL header (
file-header-template.txt), applied via
fileHeaderTemplate.
4. Dependency injection (InfomaniakDI)
Registration happens in <App>Core/TargetAssembly.swift, an open class subclassed per target:
open class TargetAssembly: @unchecked Sendable {
open class func getCommonServices() -> [Factory] {
[
Factory(type: AccountManagerable.self) { _, _ in AccountManager() },
Factory(type: TokenStore.self) { _, _ in TokenStore() }
]
}
open class func getTargetServices() -> [Factory] { [] }
public static func setupDI() {
(getCommonServices() + getTargetServices()).registerFactoriesInDI()
}
}
The app target subclasses it and instantiates the assembly very early, as a private let
hook in the @main:
@main
struct AuthenticatorApp: App {
private let dependencyInjectionHook = AuthenticatorAppTargetAssembly()
...
}
Accessing services
@LazyInjectService for a view/class property.
@InjectService locally inside a function when the call is one-off.
Rule 8 — declare @LazyInjectService as close to its use as possible. If a single view/
function needs it, do not hoist it to the top of the class. If the call is one-off, prefer a
local @InjectService:
public struct AccountsView: View {
public var body: some View { ... }
private func refreshUserProfiles() {
@InjectService var authenticatorFacade: AuthenticatorFacade
authenticatorFacade.refreshUserProfiles()
}
}
5. Persistence: KMP by default, Realm only when necessary
Realm is not the default choice. The current direction (authenticator, SwissTransfer) is
to share business logic and persistence through a KMP library (Kotlin Multiplatform);
those apps open no Realm on the iOS side. kMail still uses Realm directly, but that is
historical tech debt that should not be reproduced.
Before introducing or extending Realm, the agent must:
- Use Realm only if the module already depends on it, or if no KMP library is available
for the need.
- Not hesitate to explicitly state when KMP looks like the better solution (logic/data
shared with Android, model already exposed by the KMP library, new shared-persistence need)
rather than reaching for Realm by reflex. In that case, propose KMP and ask for confirmation
before writing new Realm code.
- If Realm is genuinely warranted, flag it as a deliberate (and module-local) choice, not the
standard path.
When Realm is the right call (Transactionable helpers)
When a module uses Realm (the kMail case), do not open a Realm by hand: go through the
transactionExecutor (type Transactionable), usually exposed by a manager.
let res = transactionExecutor.fetchResults(ofType: Ticket.self) { lazyCollection in
lazyCollection.filter("validateAt > %@", lastSync)
}
let folder = transactionExecutor.fetchObject(ofType: Folder.self, forPrimaryKey: folderId)
try transactionExecutor.writeTransaction { realm in
let allObjects = realm.objects(Object.self)
if objectsNeedToBeDeleted {
realm.delete(allObjects)
}
realm.add(parsedResult, update: .modified)
}
Often called directly on the manager that conforms to Transactionable
(e.g. mailboxManager.writeTransaction { realm in ... },
mailboxManager.fetchResults(ofType: Thread.self) { ... }).
Reminder: authenticator & SwissTransfer do not use Realm directly (everything goes through
the KMP library). Only introduce Realm if the module already depends on it — otherwise
recommend KMP.
6. SwiftUI view writing conventions
6.1 Paddings via InfomaniakCoreUI
Use the in-house modifier rather than raw values:
import InfomaniakCoreUI
RandomView
.padding(value: .medium)
For explicit EdgeInsets/spacings, use the IKPadding tokens:
VStack(spacing: IKPadding.large) { ... }
.listRowInsets(EdgeInsets(top: IKPadding.medium, leading: 0, bottom: IKPadding.large, trailing: 0))
Never hardcode magic numbers (.padding(16)).
6.2 Naming
- Types (
class / struct / enum) in CamelCase; variables and functions in camelCase.
- Identifiers:
newId, never newID (same for userId, accountId). The Id stays
camelCase; the acronym is not uppercased.
- View type names must end with a view suffix:
View, Button, TextField, or any
SwiftUI view subtype (e.g. ThreadView, MailButton, RecipientTextField).
6.3 Declaration order inside a view
Follow this order, with private where indicated:
public struct MyView: View {
@LazyInjectService private var someService: SomeServiceable
@AppStorage(...) private var someStored = ...
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var mainViewState: MainViewState
@State private var isOn: Bool
@StateObject private var viewModel = MyViewModel()
@FocusState private var isFocused: Bool
@Binding var password: String
@ObservedObject var observed: SomeObject
@ObservedRealmObject var realmObject: SomeRealmObject
@ObservedResults(Item.self) private var items
let title: String
private var isButtonDisabled: Bool {
isOn && !isPasswordValid
}
init(password: Binding<String>) {
_password = password
}
var body: some View {
...
}
}
private is required (enforced by SwiftLint custom_rules) on:
@State, @StateObject, @Environment, @EnvironmentObject, @ModalState,
@ObservedResults. Exception: @Previewable in previews.
6.4 Localization, colors, images
Always through the module's generated resources (<App>Resources), e.g.
AuthenticatorResourcesStrings.…, Color.ST.textPrimary, …Asset.Images.….swiftUIImage —
never a hardcoded string/color.
6.5 Previews
Provide a #Preview that injects shared state via PreviewHelper:
#Preview {
AccountsView()
.environmentObject(PreviewHelper.sampleMainViewState)
}
6.6 Modal presentation (sheets)
-
Sheet state property: Use isShowing<ViewName>: Bool.
- Example:
isShowingThreadView.
-
Placement: Place .sheet as close to the call site as possible (on the Button or its
immediate container).
- This avoids unnecessary state updates at higher levels.
- It also provides a direct anchor point for
.popover on iPad.
-
Preferred pattern: Encapsulate the trigger button + sheet in its own small view with its
own @State. This keeps parent views clean and avoids hoisted state.
✅ Do — encapsulated button:
struct MyView: View {
var body: some View {
HStack {
SheetButton()
}
}
}
struct SheetButton: View {
@State private var isShowingSomeView = false
var body: some View {
Button {
isShowingSomeView = true
} label: {
Text("Show")
}
.sheet(isPresented: $isShowingSomeView) {
SomeView()
}
}
}
❌ Don't — detached sheet at parent level:
struct MyView: View {
@State private var isShowingSomeView = false
var body: some View {
HStack {
Button {
isShowingSomeView = true
} label: {
Text("Show")
}
}
.sheet(isPresented: $isShowingSomeView) {
SomeView()
}
}
}
7. Concurrency (Swift 6, strict concurrency)
- Annotate
Sendable / @MainActor as soon as it is simple and natural — don't leave the
compiler guessing.
@MainActor is a last resort, reserved for UI-bound objects (view models, screen-level
observable state). Don't sprinkle it on business logic that can stay Sendable.
- Prefer making a type
Sendable (value type, let, immutable data) over pinning it to the
main actor.
- For non-strict libs,
@preconcurrency import … is acceptable
(e.g. @preconcurrency import InfomaniakCore).
8. Pre-delivery checklist
Installing this skill
This skill lives in skill_infomaniak/. To make it discoverable by opencode, place it
(or symlink it) under a skills directory:
- personal:
~/.config/opencode/skills/infomaniak-swiftui/SKILL.md
- project:
<repo>/.opencode/skills/infomaniak-swiftui/SKILL.md
The folder name must match the name field in the frontmatter.