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
- Core tracking: start/stop local sessions, display history (1 week). Screens: Sessions list, New session. Models: Session. Persistence: LocalStore JSON.
- UI polish & accessibility: dynamic type, VoiceOver labels, color contrast.
- Sync MVP: implement API and SessionStore sync + conflict merge. Add Sync button and background fetch for pull.
- Auth & multi-device: add sign-in, token storage, secure networking (HTTPS, refresh). Use Keychain for tokens.
- Data model improvements: migrate to SwiftData/Core Data for relationships, analytics, and migrations.
- 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.