Try it free
Write faster with AI writing tools
WriterPilots gives you 10+ free AI writing tools — blog writer, email writer, paraphraser, grammar fixer and more. Free to try, no credit card needed.
Try Blog Writer → Paraphraser Summarizer Grammar Fixer
Blog Post

Build FocusFlow: Roadmap + Implementing the Focus Session Sync Feature (SwiftUI, MVVM, Async/Await)

March 22, 2026 · 7 min read · 17 views

Build FocusFlow: Roadmap + Implementing the Focus Session Sync Feature

This article teaches an intermediate iOS developer how to turn the FocusFlow idea into an MVP roadmap, then walks through a production-minded implementation of the Focus Session Sync feature using SwiftUI, MVVM, async/await networking, and a local cache. You’ll get file structure guidance, models, a networking layer, a view model, and a SwiftUI view with previews.

Why FocusFlow and the Sync Feature?

FocusFlow is a Pomodoro-like app that tracks focused sessions and streaks. Sync lets users keep sessions consistent across devices—important for analytics, streaks, and backups.

High-level components needed

  • Session model: Codable & Identifiable
  • Networking layer: APIClient using async/await
  • Local cache: safe file-based JSON cache (fast to ship) with SwiftData/CoreData note for production
  • SessionStore (ViewModel): ObservableObject that handles CRUD + sync logic + conflict resolution
  • SwiftUI views: list of sessions, start button, and sync state

Recommended file structure

FocusFlow/

Models/

Session.swift

Networking/

APIClient.swift

Persistence/

LocalStore.swift

ViewModels/

SessionStore.swift

Views/

SessionsView.swift

SessionRowView.swift

App.swift

Design choices & local persistence

For an MVP, I recommend a small file-backed JSON cache (LocalStore) because it’s simple, cross-version, and easy to test. For iOS 17+ apps with long-term needs, migrate to SwiftData/Core Data for queries, relationships, and background context merging. This tutorial implements the JSON local cache and includes notes where to swap in SwiftData.

Models

We use a single Codable Session DTO for both local cache and networking.

import Foundation

struct Session: Identifiable, Codable, Equatable {
    var id: UUID
    var title: String
    var duration: TimeInterval
    var startedAt: Date
    var completed: Bool
    var lastModified: Date

    init(id: UUID = UUID(), title: String, duration: TimeInterval, startedAt: Date = Date(), completed: Bool = false, lastModified: Date = Date()) {
        self.id = id
        self.title = title
        self.duration = duration
        self.startedAt = startedAt
        self.completed = completed
        self.lastModified = lastModified
    }
}

Networking: simple API client

Use a small APIClient with async/await and Codable. This client handles GET and POST for sessions. Add authentication and exponential backoff in production.

import Foundation

final class APIClient {
    private let baseURL: URL
    private let urlSession: URLSession

    init(baseURL: URL, urlSession: URLSession = .shared) {
        self.baseURL = baseURL
        self.urlSession = urlSession
    }

    func fetchSessions() async throws -> [Session] {
        let url = baseURL.appendingPathComponent("/sessions")
        let (data, response) = try await urlSession.data(from: url)
        try validate(response: response)
        return try JSONDecoder().decode([Session].self, from: data)
    }

    func upload(session: Session) async throws -> Session {
        let url = baseURL.appendingPathComponent("/sessions")
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try JSONEncoder().encode(session)
        let (data, response) = try await urlSession.data(for: request)
        try validate(response: response)
        return try JSONDecoder().decode(Session.self, from: data)
    }

    private func validate(response: URLResponse) throws {
        guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
            throw URLError(.badServerResponse)
        }
    }
}

Local cache (file-backed)

import Foundation

final class LocalStore {
    private let fileURL: URL
    private let queue = DispatchQueue(label: "LocalStoreQueue", attributes: .concurrent)

    init(filename: String = "sessions.json") {
        let fm = FileManager.default
        self.fileURL = fm.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent(filename)
    }

    func load() -> [Session] {
        guard let data = try? Data(contentsOf: fileURL) else { return [] }
        return (try? JSONDecoder().decode([Session].self, from: data)) ?? []
    }

    func save(_ sessions: [Session]) {
        queue.async(flags: .barrier) {
            guard let data = try? JSONEncoder().encode(sessions) else { return }
            try? data.write(to: self.fileURL, options: .atomic)
        }
    }
}

ViewModel: SessionStore

SessionStore coordinates local state and remote sync. It uses optimistic updates and resolves conflicts by lastModified (simple policy).

import Foundation
import Combine

@MainActor
final class SessionStore: ObservableObject {
    @Published private(set) var sessions: [Session] = []
    @Published var isSyncing = false
    private let api: APIClient
    private let local: LocalStore

    init(api: APIClient, local: LocalStore) {
        self.api = api
        self.local = local
        self.sessions = local.load()
    }

    func addSession(title: String, duration: TimeInterval) async {
        var new = Session(title: title, duration: duration)
        sessions.insert(new, at: 0)
        local.save(sessions)

        // optimistic upload
        do {
            isSyncing = true
            let uploaded = try await api.upload(session: new)
            // reconcile: replace local with server response if newer
            if let idx = sessions.firstIndex(where: { $0.id == uploaded.id }) {
                if uploaded.lastModified >= sessions[idx].lastModified {
                    sessions[idx] = uploaded
                    local.save(sessions)
                }
            }
        } catch {
            // keep local copy and mark for retry, or queue a retry policy
            print("Upload failed: \(error)")
        }
        isSyncing = false
    }

    func pullRemote() async {
        do {
            isSyncing = true
            let remote = try await api.fetchSessions()
            // simple merge strategy: prefer item with newer lastModified
            var merged = Dictionary(uniqueKeysWithValues: sessions.map { ($0.id, $0) })
            for r in remote {
                if let localItem = merged[r.id] {
                    merged[r.id] = (r.lastModified > localItem.lastModified) ? r : localItem
                } else {
                    merged[r.id] = r
                }
            }
            sessions = merged.values.sorted(by: { $0.startedAt > $1.startedAt })
            local.save(sessions)
        } catch {
            print("Pull failed: \(error)")
        }
        isSyncing = false
    }
}

View: SessionsView

import SwiftUI

struct SessionsView: View {
    @StateObject var store: SessionStore
    @State private var isPresentingNew = false

    var body: some View {
        NavigationView {
            List(store.sessions) { session in
                VStack(alignment: .leading) {
                    Text(session.title).font(.headline)
                    Text(session.startedAt, style: .time).font(.subheadline)
                }
            }
            .navigationTitle("Focus Sessions")
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button(action: { isPresentingNew = true }) {
                        Image(systemName: "plus")
                    }
                }
                ToolbarItem(placement: .navigationBarLeading) {
                    if store.isSyncing { ProgressView() } else {
                        Button("Sync") {
                            Task { await store.pullRemote() }
                        }
                    }
                }
            }
            .sheet(isPresented: $isPresentingNew) {
                NewSessionView { title, duration in
                    Task { await store.addSession(title: title, duration: duration) }
                    isPresentingNew = false
                }
            }
        }
    }
}

struct NewSessionView: View {
    @Environment(\.dismiss) var dismiss
    @State private var title = "Focus"
    @State private var duration: Double = 25 * 60
    var onCreate: (String, TimeInterval) -> Void

    var body: some View {
        NavigationView {
            Form {
                TextField("Title", text: $title)
                Stepper(value: $duration, in: 5*60...60*60, step: 5*60) {
                    Text("Duration: \(Int(duration/60)) min")
                }
            }
            .navigationTitle("New Session")
            .toolbar {
                ToolbarItem(placement: .confirmationAction) {
                    Button("Start") {
                        onCreate(title, duration)
                        dismiss()
                    }
                }
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") { dismiss() }
                }
            }
        }
    }
}

Preview wiring

In previews, inject a stubbed API client that returns predictable data. For brevity, the preview below uses the real LocalStore and a mock API that returns the same sessions.

Roadmap: MVP milestones

  1. Core tracking: start/stop local sessions, display history (1 week). Screens: Sessions list, New session. Models: Session. Persistence: LocalStore JSON.
  2. UI polish & accessibility: dynamic type, VoiceOver labels, color contrast.
  3. Sync MVP: implement API and SessionStore sync + conflict merge. Add Sync button and background fetch for pull.
  4. Auth & multi-device: add sign-in, token storage, secure networking (HTTPS, refresh). Use Keychain for tokens.
  5. Data model improvements: migrate to SwiftData/Core Data for relationships, analytics, and migrations.
  6. App Store readiness: analytics, crash reporting, thorough testing (unit, UI), screenshots, privacy policy.

Common pitfalls and tips

  • Don’t block the main thread when saving large caches—use background queues.
  • Keep network errors visible to users (snackbars) and retry automatically when network resumes.
  • Avoid irreversible deletes without backup; keep a short undo buffer or soft-delete flag.
  • Start with a simple merge policy (lastModified) and iterate toward CRDTs or server-driven merges if needed.

Wrap-up

This tutorial gives you a practical path from idea to a working Focus Session Sync feature. You learned the required components, the file structure, a minimal but production-minded implementation using MVVM, async networking, and a safe local cache. From here, add auth, robust retry/backoff, and migrate persistence to SwiftData for a long-lived product.

Want the full Xcode project skeleton and mock API stubs? Tell me which iOS deployment target you’re targeting (iOS 16 vs 17+) and I’ll generate a ready-to-run project with tests and a SwiftData migration plan.

Related Posts