Créer une app iOS seul avec Swift : architecture, offline et gamification
Alexandre
··
Durée de lecture : 8 min
Zéro expérience en Swift. Aucun budget. Un seul dev. Et pourtant, 8 mois plus tard, Livate était sur l'App Store. Voici exactement comment j'ai fait.
Si tu es développeur, indie hacker, ou simplement curieux de savoir ce qu'il y a sous le capot d'une app mobile en 2026, cet article est pour toi. Pour le contexte, tu peux lire mon parcours et pourquoi j'ai créé Livate.
Swift natif : pourquoi j'ai refusé le cross-platform
La première décision, et probablement la plus structurante : Swift natif ou cross-platform ?
Cross-platform
Plus rapide au début
Performance correcte
Animations limitées
Widgets pas natifs
Dynamic Island compliqué
Swift natif — mon choix
Plus lent au début
Performance optimale
Animations ultra fluides
Widgets accès complet
Dynamic Island natif
J'ai choisi Swift + SwiftUI pour une raison simple : Livate est une app qu'on ouvre chaque jour. La fluidité, le haptic feedback, les transitions natives — tout ça se ressent. Et surtout, je voulais les widgets iOS, le Dynamic Island et les Live Activities. En cross-platform, ça aurait été un cauchemar.
Comme je le racontais dans mon aventure, je ne connaissais rien à Swift en commençant. J'ai passé les 3 premières semaines à apprendre le langage en parallèle du dev. C'était intense, mais chaque petit progrès était grisant.
L'architecture : des modules isolés par feature
Le projet est structuré en modules par feature. Chaque feature a ses propres Models, ViewModels, Services et Views :
Cette séparation a un vrai avantage : quand je bosse sur les badges, je ne casse pas le Flow. Quand j'ajoute un widget, je ne touche pas aux stats. Ça paraît évident, mais au début je mettais tout dans 3 fichiers. Le jour où FlowViewModel.swift a dépassé 1800 lignes, j'ai compris qu'il fallait restructurer. Ça m'a pris une semaine complète de refactoring. Douloureux, mais nécessaire.
Le Flow : le cœur de l'app
Le Flow, c'est le concept central de Livate — celui que j'expliquais dans pourquoi j'ai créé Livate. En quelques taps, tu définis ton objectif, tu le découpes en micro-actions, et tu passes à l'action.
Voici le modèle qui porte tout ça :
struct FlowLivate: Codable, Identifiable {
let id: String
var libelleAction: String // L'action du jour
var objectif: String? // Objectif lié
var etat: FlowEtat // créé, en_cours, terminé
var energieRequise: Int? // 1-5
var tempsEstime: Int? // Minutes estimées
var tempsRealise: Double? // Temps réel passé
var contexte: [String]? // ["fitness", "productivity"]
var streakDay: Int // Jours consécutifs
var etape: [FlowLivateStep] // Étapes de l'action
}
Chaque Flow est rattaché à un des 9 objectifs prédéfinis (3 par thématique : Fitness, Productivité, Routine). L'idée, c'est de ne jamais laisser l'utilisateur face à une page blanche. Tu choisis un objectif, et le Flow te guide.
Le streakDay est là pour la gamification : plus tu enchaînes les jours, plus ton streak grandit. C'est subtil mais ça crée une boucle d'engagement naturelle.
Le choix technique dont je suis le plus fier : Livate fonctionne offline. Pas de loader, pas de "vérifiez votre connexion". Tu ouvres l'app, tes données sont là.
Le secret, c'est le pattern cache-first : on affiche d'abord depuis le cache local (instantané), puis on synchronise avec le serveur en arrière-plan.
@MainActor
class FlowLivateViewModel: ObservableObject {
@Published var flows: [FlowLivate] = []
func loadFlows(for date: String) async {
// 1. Affichage instantané depuis le cache
if let cached = await cacheManager.getCachedFlows(userId: userId) {
self.flows = cached
}
// 2. Offline ? On s'arrête là. L'app marche quand même
guard networkMonitor.isConnected else { return }
// 3. Sync API en arrière-plan
let freshFlows = try await repository.fetchFlows(date: date)
self.flows = freshFlows
await cacheManager.cacheFlows(freshFlows, userId: userId)
}
}
Le PremiumCacheManager est un actor Swift (thread-safe par design) avec deux niveaux :
L1 (RAM) : dictionnaire en mémoire, ultra rapide
L2 (UserDefaults) : persistant, survit au redémarrage
Quand l'utilisateur est offline, les opérations (compléter une tâche, logger un mood) sont mises dans une file d'attente (OfflineOperationQueue). Dès que la connexion revient, tout se synchronise automatiquement. L'utilisateur ne voit rien.
Est-ce que UserDefaults est le choix parfait pour du cache L2 ? Non. Core Data ou SwiftData seraient plus robustes à grande échelle. Mais pour un MVP, c'est pragmatique et ça marche. J'itérerai quand le volume de données le justifiera.
Les micro-habitudes : 3 états, c'est tout
Les tâches sont les micro-actions concrètes de ton Flow. J'ai fait un choix radical de simplicité :
enum TaskState: String, Codable {
case aFaire = "a_faire" // Rouge
case enCours = "en_cours" // Orange
case terminee = "terminee" // Vert
}
Pas de sous-tâches, pas de labels, pas de priorités à 5 niveaux. À faire, en cours, terminée. Point. Quand une tâche passe au vert, le système de badges se déclenche et les stats se mettent à jour automatiquement.
Un détail technique que j'aime bien : chaque tâche a un double identifiant — id: Int? (backend cloud) et idLocal: UUID? (local). Les utilisateurs free travaillent uniquement en local. Quand ils passent premium, le LocalDataMigrationService transfère tout vers le cloud de manière transparente.
Cliquer pour agrandir
Le Focus : Pomodoro dans le Dynamic Island
Le mode Focus, c'est un timer Pomodoro avec un twist iOS : il s'affiche dans le Dynamic Island et sur l'écran de verrouillage via les Live Activities.
Tu lances une session, tu verrouilles ton écran, et tu vois ta progression en temps réel sans rouvrir l'app. C'est exactement le genre de feature qui justifie le choix du natif : en Flutter, ça aurait nécessité 3 plugins, 2 bridges, et probablement un sacrifice rituel.
L'implémentation repose sur ActivityKit :
struct FocusTimerAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
var remainingSeconds: Int
var totalSeconds: Int
var isRunning: Bool
var progress: Double {
1.0 - (Double(remainingSeconds) / Double(totalSeconds))
}
}
}
Le widget sur l'écran d'accueil se met aussi à jour en temps réel via un App Group partagé. C'est une des features qui revient le plus dans les retours utilisateurs.
La gamification : 5 badges par jour
Pour garder la motivation, chaque jour tu peux débloquer 5 badges. Les 4 premiers sont des défis indépendants, et le 5ème récompense la régularité :
Connexion matinale — se connecter avant 10h
3 actions complétées — terminer 3 tâches dans la journée
Focus 15 minutes — réaliser une session de concentration
Flow terminé — compléter un flow du jour
Badge ultime — obtenir les 4 badges précédents
C'est un système progressif : chaque badge est accessible, mais les obtenir tous les 5 dans la même journée demande un vrai engagement. La mascotte Livate t'accompagne avec une animation de récompense à chaque badge débloqué — un petit détail qui rend le moment satisfaisant.
Livate propose des widgets pour l'écran d'accueil et l'écran de verrouillage. Petit, moyen, grand — chacun affiche ton flow actif et tes tâches en cours.
La synchronisation passe par un App Group. Quand tu complètes une tâche dans l'app, le widget se met à jour instantanément :
func updateFlowWidget(with flow: FlowLivate) {
let encoded = try? JSONEncoder().encode(widgetData)
let defaults = UserDefaults(suiteName: "group.com.livate.widgets")
defaults?.set(encoded, forKey: "flowData")
// Rafraîchir le widget immédiatement
WidgetCenter.shared.reloadTimelines(ofKind: "FlowWidget")
}
Tu vois ta progression avancer directement sur ton écran d'accueil, sans même ouvrir Livate. C'est un rappel visuel constant qui pousse à l'action.
Cliquer pour agrandir
Le Design System : une décision qui m'a sauvé
Au début, je codais mes couleurs et mes espacements en dur. Chaque écran avait ses propres valeurs. Au bout de 3 mois, c'était ingérable : changer une couleur impliquait de modifier 40 fichiers.
J'ai tout centralisé dans des design tokens :
enum LivateDesignTokens {
// Couleurs
static let accentPrimary = Color(red: 0, green: 0.6, blue: 0.76)
// Typo (SF Pro Rounded)
static let titleLarge: Font = .system(size: 34, weight: .bold, design: .rounded)
static let body: Font = .system(size: 17, weight: .regular, design: .rounded)
// Grille 8pt
static let spacingS: CGFloat = 16
static let spacingM: CGFloat = 24
// Animations standardisées
static let springStandard = Animation.spring(duration: 0.4, bounce: 0.3)
// Haptic feedback
static func hapticLight() {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
}
Maintenant, quand je veux changer une couleur ou un espacement, je le fais à un seul endroit. Ça m'a pris une semaine à mettre en place. Ça m'a fait gagner des mois depuis.
Les galères que je n'oublierai pas
L'article serait incomplet sans les moments où j'ai failli tout casser :
Le NavigationStack qui perd son état. J'ai passé 4 jours sur un bug où la navigation SwiftUI réinitialisait tout le ViewModel à chaque retour en arrière. Le fix ? Un @StateObject mal placé. 4 jours pour déplacer une ligne de code.
Le parsing de dates multi-format. Mon backend envoie des dates en "yyyy-MM-dd". Sauf quand il envoie du "yyyy-MM-dd HH:mm:ss.SSS". Sauf quand il envoie de l'ISO 8601. J'ai fini par écrire un custom init(from decoder:) de 60 lignes avec 3 formats en fallback. Pas élégant, mais ça marche.
La review Apple rejetée. 3 fois. Je pensais que la soumission App Store serait une formalité. En réalité, j'ai essuyé 3 rejets consécutifs : le premier pour des problèmes légaux (mentions manquantes, conditions d'utilisation), le deuxième pour le design system (inconsistances visuelles, guidelines Apple non respectées), et le troisième pour le système premium/free (distinction pas assez claire entre les fonctionnalités). À chaque rejet, il faut corriger, resoumettre, et attendre. Stressant, mais ça m'a forcé à livrer une app beaucoup plus propre que ce que j'aurais fait sans ces retours.
Ce que j'ai appris
1. Le cache-first, c'est non négociable. Personne ne veut attendre 3 secondes qu'une requête API revienne. Charge depuis le cache, affiche instantanément, sync en arrière-plan.
2. Commence avec un design system. J'ai commencé sans, j'ai dû tout reprendre. Avec les LivateDesignTokens, chaque nouveau composant est cohérent dès le premier rendu.
3. SwiftUI est génial, mais les NavigationStack perdent leur état. Les animations déclaratives sont incroyables. Le debugging de la navigation, beaucoup moins.
4. 5 badges par jour et un streak suffisent. La gamification subtile fonctionne mieux que les systèmes complexes. Les utilisateurs reviennent pour ne pas casser leur streak.
5. L'IA aide énormément en solo. Que ce soit pour analyser un problème, brainstormer une architecture, ou accélérer le développement, des outils comme Claude et Copilot m'ont fait gagner un temps précieux. En 2026, c'est devenu indispensable dans mon workflow quotidien.
Et la suite ?
Livate continue d'évoluer : fonctionnalités sociales, suggestions IA, widgets encore plus riches, et peut-être un jour une version Android.
Mais pour l'instant, je me concentre sur une chose : écouter les retours utilisateurs. Si tu utilises Livate, n'hésite pas à me faire un retour — chaque commentaire me permet d'avancer.
Dans le prochain article, je te parlerai de la méthode Flow — le concept central de Livate et comment il peut t'aider à passer à l'action au quotidien.
À très vite !
Alex
À retenir
Swift natif plutôt que cross-platform : plus de travail au début, mais performance native, accès complet aux APIs Apple et zéro compromis UX.
Cache-first = l'app marche sans internet. Pas de loader, pas de 'vérifiez votre connexion'. L'utilisateur ouvre, ses données sont là.
Le Design System m'a sauvé : 1 source de vérité pour couleurs, typo, espacement. Sans ça, chaque écran aurait été un cauchemar de cohérence.
Les micro-habitudes en 3 états (à faire, fait, raté) sont le coeur de Livate. Simple à comprendre, addictif à utiliser.
Zéro expérience Swift au départ. 8 mois plus tard, app en prod sur l'App Store. En 2026, la barrière d'entrée n'existe plus.