Dev

Building an iOS app solo with Swift: architecture, offline and gamification

Alexandre
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:
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.
Theme and goal selection in the Livate Flow

Click to enlarge

Breaking down into micro-actions and Flow dashboard in Livate

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 identifierid: 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.
The 3 task states in Livate: to do, in progress, done

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:
  1. Morning check-in — log in before 10 AM
  2. 3 actions completed — finish 3 tasks in the day
  3. 15-minute focus — complete a focus session
  4. Flow completed — finish a daily flow
  5. 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.
Daily badges in Livate - 5 daily badge system

Click to enlarge

Reward animation with the Livate mascot

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.
Livate widgets on the iOS home screen - medium and small

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.

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?

This site uses cookies for analytics and advertising. No personal data is sold. Learn more