Building an iOS app solo with Swift: architecture, offline and gamification
Alexandre
··
Reading time: 7 min
Zero Swift experience. No budget. A single dev. And yet, 8 months later, Livate was on the App Store. Here's exactly how I did it.
If you're a developer, indie hacker, or just curious about what's under the hood of a mobile app in 2026, this article is for you. For context, you can read my journey and why I created Livate.
Native Swift: why I turned down cross-platform
The first decision, and probably the most impactful one: native Swift or cross-platform?
Cross-platform
Faster to start
Decent performance
Limited animations
Non-native widgets
Dynamic Island is tricky
Native Swift — my choice
Slower to start
Optimal performance
Buttery smooth animations
Full widget access
Native Dynamic Island
I chose Swift + SwiftUI for a simple reason: Livate is an app you open every day. The fluidity, the haptic feedback, the native transitions — you can feel all of it. And above all, I wanted iOS widgets, Dynamic Island, and Live Activities. In cross-platform, that would have been a nightmare.
As I mentioned in my journey, I knew nothing about Swift when I started. I spent the first 3 weeks learning the language while building the app. It was intense, but every small win was exhilarating.
The architecture: isolated modules per feature
The project is structured into feature-based modules. Each feature has its own Models, ViewModels, Services, and Views:
This separation has a real advantage: when I'm working on badges, I don't break the Flow. When I add a widget, I don't touch the stats. It seems obvious, but early on I had everything in 3 files. The day FlowViewModel.swift exceeded 1,800 lines, I knew it was time to restructure. It took me a full week of refactoring. Painful, but necessary.
The Flow: the heart of the app
The Flow is Livate's core concept — the one I explained in why I created Livate. In a few taps, you define your goal, break it into micro-actions, and get to work.
Here's the model that powers it all:
struct FlowLivate: Codable, Identifiable {
let id: String
var libelleAction: String // Today's action
var objectif: String? // Related goal
var etat: FlowEtat // created, in_progress, completed
var energieRequise: Int? // 1-5
var tempsEstime: Int? // Estimated minutes
var tempsRealise: Double? // Actual time spent
var contexte: [String]? // ["fitness", "productivity"]
var streakDay: Int // Consecutive days
var etape: [FlowLivateStep] // Action steps
}
Each Flow is attached to one of 9 predefined goals (3 per theme: Fitness, Productivity, Routine). The idea is to never leave the user staring at a blank page. You pick a goal, and the Flow guides you.
The streakDay is there for gamification: the more consecutive days you chain, the longer your streak grows. It's subtle, but it creates a natural engagement loop.
The technical choice I'm most proud of: Livate works offline. No loader, no "check your connection" message. You open the app, your data is there.
The secret is the cache-first pattern: display from the local cache first (instant), then sync with the server in the background.
@MainActor
class FlowLivateViewModel: ObservableObject {
@Published var flows: [FlowLivate] = []
func loadFlows(for date: String) async {
// 1. Instant display from cache
if let cached = await cacheManager.getCachedFlows(userId: userId) {
self.flows = cached
}
// 2. Offline? We stop here. The app still works
guard networkMonitor.isConnected else { return }
// 3. Background API sync
let freshFlows = try await repository.fetchFlows(date: date)
self.flows = freshFlows
await cacheManager.cacheFlows(freshFlows, userId: userId)
}
}
The PremiumCacheManager is a Swift actor (thread-safe by design) with two levels:
L1 (RAM): in-memory dictionary, ultra fast
L2 (UserDefaults): persistent, survives restarts
When the user is offline, operations (completing a task, logging a mood) are queued in an offline operation queue (OfflineOperationQueue). As soon as connectivity returns, everything syncs automatically. The user doesn't notice a thing.
Is UserDefaults the perfect choice for L2 cache? No. Core Data or SwiftData would be more robust at scale. But for an MVP, it's pragmatic and it works. I'll iterate when the data volume justifies it.
Micro-habits: 3 states, that's it
Tasks are the concrete micro-actions of your Flow. I made a radical simplicity choice:
enum TaskState: String, Codable {
case aFaire = "a_faire" // Red
case enCours = "en_cours" // Orange
case terminee = "terminee" // Green
}
No subtasks, no labels, no 5-level priorities. To do, in progress, done. Period. When a task turns green, the badge system triggers and stats update automatically.
A technical detail I like: each task has a dual identifier — id: Int? (cloud backend) and idLocal: UUID? (local). Free users work entirely locally. When they upgrade to premium, the LocalDataMigrationService seamlessly transfers everything to the cloud.
Click to enlarge
Focus: Pomodoro in the Dynamic Island
The Focus mode is a Pomodoro timer with an iOS twist: it shows up in the Dynamic Island and on the lock screen via Live Activities.
You start a session, lock your screen, and see your progress in real time without reopening the app. This is exactly the kind of feature that justifies going native: in Flutter, it would have required 3 plugins, 2 bridges, and probably a ritual sacrifice.
The implementation relies on 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))
}
}
}
The home screen widget also updates in real time via a shared App Group. It's one of the features that comes up the most in user feedback.
Gamification: 5 badges per day
To keep motivation high, you can unlock 5 badges every day. The first 4 are independent challenges, and the 5th rewards consistency:
Morning check-in — log in before 10 AM
3 actions completed — finish 3 tasks in the day
15-minute focus — complete a focus session
Flow completed — finish a daily flow
Ultimate badge — earn all 4 previous badges
It's a progressive system: each badge is achievable, but earning all 5 in the same day takes real commitment. The Livate mascot celebrates each unlocked badge with a reward animation — a small detail that makes the moment satisfying.
Livate offers widgets for both the home screen and the lock screen. Small, medium, large — each displays your active flow and current tasks.
The sync goes through an App Group. When you complete a task in the app, the widget updates instantly:
func updateFlowWidget(with flow: FlowLivate) {
let encoded = try? JSONEncoder().encode(widgetData)
let defaults = UserDefaults(suiteName: "group.com.livate.widgets")
defaults?.set(encoded, forKey: "flowData")
// Refresh the widget immediately
WidgetCenter.shared.reloadTimelines(ofKind: "FlowWidget")
}
You see your progress move forward right on your home screen, without even opening Livate. It's a constant visual reminder that pushes you to take action.
Click to enlarge
The Design System: a decision that saved me
Early on, I was hardcoding my colors and spacing. Each screen had its own values. After 3 months, it was unmanageable: changing a single color meant editing 40 files.
I centralized everything into design tokens:
enum LivateDesignTokens {
// Colors
static let accentPrimary = Color(red: 0, green: 0.6, blue: 0.76)
// Typography (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)
// 8pt grid
static let spacingS: CGFloat = 16
static let spacingM: CGFloat = 24
// Standardized animations
static let springStandard = Animation.spring(duration: 0.4, bounce: 0.3)
// Haptic feedback
static func hapticLight() {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
}
Now, when I want to change a color or spacing, I do it in one single place. It took me a week to set up. It's saved me months since then.
The struggles I'll never forget
This article would be incomplete without the moments I almost broke everything:
The NavigationStack losing its state. I spent 4 days on a bug where SwiftUI's navigation would reset the entire ViewModel every time I navigated back. The fix? A misplaced @StateObject. 4 days to move a single line of code.
Multi-format date parsing. My backend sends dates in "yyyy-MM-dd". Except when it sends "yyyy-MM-dd HH:mm:ss.SSS". Except when it sends ISO 8601. I ended up writing a custom init(from decoder:) of 60 lines with 3 fallback formats. Not elegant, but it works.
The Apple review rejection. 3 times. I thought the App Store submission would be a formality. In reality, I got 3 consecutive rejections: the first for legal issues (missing disclosures, terms of use), the second for the design system (visual inconsistencies, Apple guidelines not followed), and the third for the premium/free system (not enough distinction between features). Each rejection meant fixing, resubmitting, and waiting. Stressful, but it forced me to ship a much cleaner app than I would have without that feedback.
What I learned
1. Cache-first is non-negotiable. Nobody wants to wait 3 seconds for an API request to come back. Load from cache, display instantly, sync in the background.
2. Start with a design system. I started without one and had to redo everything. With LivateDesignTokens, every new component is consistent from the first render.
3. SwiftUI is amazing, but NavigationStack loses its state. Declarative animations are incredible. Debugging navigation, much less so.
4. 5 daily badges and a streak are enough. Subtle gamification works better than complex systems. Users come back so they don't break their streak.
5. AI helps enormously when you're solo. Whether it's analyzing a problem, brainstorming an architecture, or speeding up development, tools like Claude and Copilot saved me a ton of time. In 2026, it's become essential in my daily workflow.
What's next?
Livate keeps evolving: social features, AI suggestions, even richer widgets, and maybe one day an Android version.
But for now, I'm focused on one thing: listening to user feedback. If you use Livate, don't hesitate to share your thoughts — every comment helps me move forward.
In the next article, I'll tell you about the Flow method — Livate's core concept and how it can help you take action every day.
See you soon!
Alex
Key takeaways
Native Swift over cross-platform: more work upfront, but native performance, full access to Apple APIs, and zero UX compromises.
Cache-first = the app works without internet. No loader, no 'check your connection'. The user opens the app, their data is there.
The Design System saved me: 1 single source of truth for colors, typography, spacing. Without it, every screen would have been a consistency nightmare.
Micro-habits in 3 states (to do, in progress, done) are the heart of Livate. Simple to understand, addictive to use.
Zero Swift experience at the start. 8 months later, app live on the App Store. In 2026, the barrier to entry no longer exists.