← Back to articlesDev
Building an iOS app solo with Swift: architecture, offline and gamification
As a solo dev, I built Livate from scratch in native Swift. MVVM architecture, cache-first, widgets, Dynamic Island: a full behind-the-scenes look with real code.
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:
Livate/
├── Features/
│ ├── Flow/ # Goals and guided journey
│ ├── Task/ # Daily micro-habits
│ ├── Focus/ # Pomodoro timer + Dynamic Island
│ ├── Stats/ # Statistics and daily score
│ ├── BadgeReward/ # Gamification (5 badges/day)
│ ├── Home/ # Dashboard + Mood tracking
│ └── Authentication/
│
├── Core/
│ ├── Services/ # Network, Cache, Security, Sync
│ └── Components/ # Design System (LivateDesignTokens)
│
└── LivateWidgetExtension/ # Home + Lock Screen Widgets
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.
Click to enlarge

Click to enlarge
Want to see the Flow in action? Download Livate for free.
Cache-first: the app works without internet
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.

Click to enlarge

Click to enlarge
Want to try the gamification? Livate is free on the App Store.
Widgets: Livate on your home screen
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
- 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.
Related articles

AI News October 16, 2026: Anthropic books $16.8B in a quarter, Gemini 4 Argon at $2, Character.ai goes bankrupt
Anthropic reports $16.8B in Q3 ahead of its IPO, Gemini 4 Argon ships at $2, Meta teams up with Apple, Microsoft takes on OpenAI and Character.ai files Chapter 11.
AI News October 11, 2026: Mistral Large 4 goes open-weight, OpenAI publishes 722 math manuscripts, Haiku 5.5 at $0.10
OpenAI publishes 722 math manuscripts, Mistral launches Large 4 open-weight, Anthropic ships Haiku 5.5 at $0.10, and OpenAI's revenue figure sends the Nasdaq down.
Meta launches Muse, Muse Code and Muse Glimmer: inside its new AI lineup
Muse, Meta's OpenClaw-inspired personal agent, hit 500,000 users in 7 days. Models, pricing, the Muse Charm device, availability: what ships and when.Comments
Comments
Got a take on this article?
Create a free account in 10 seconds to comment, like, and get the next articles straight to your inbox.
Don't have an account yet?