← All repositories

listless

A simple list app for Apple platforms

git clone https://code.inqk.net/listless.git


commit ba6a57c47cfcfb75a350c0f741b8b30f73f1e87d
parent 6f0ae01f4b7c096600de1be606a3a11513518a99
Author: Michael Camilleri <[email protected]>
Date:   Mon, 30 Mar 2026 11:44:51 +0900

Add tutorial to iOS version

This commit adds a tutorial when running the iOS version for the first
time. A 'Finish' button is provided so that the user can quickly dismiss
this if unnecessary.

Co-Authored-By: Claude 4.6 Opus <[email protected]>

Diffstat

MListless.xcodeproj/project.pbxproj | 4++++
MListlessiOS/Extensions/ItemListView+NavigationHeader.swift | 46+++++++++++++++++++++++++++++++++++++---------
AListlessiOS/Helpers/TutorialSeeder.swift | 31+++++++++++++++++++++++++++++++
MListlessiOS/ListlessiOSApp.swift | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
MListlessiOS/Views/ItemListView.swift | 37++++++++++++++++++++++++++++++++++++-
MListlessiOS/Views/SettingsView.swift | 5+++++

6 files changed, 182 insertions(+), 37 deletions(-)

diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj

@@ -64,6 +64,7 @@
 		90BC899E66B98517A91F2627 /* ItemListView+Drag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D4ED76F5996A4308D2BC7C8 /* ItemListView+Drag.swift */; };
 		930AE396D982D7C46E498311 /* ItemEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138DCA35ED82A745E4745175 /* ItemEntity.swift */; };
 		96617677059FABDBB80D642B /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; };
+		9853BD0C426C4F4348D08E8F /* TutorialSeeder.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8F7B7D46010E217FC0A5BFF /* TutorialSeeder.swift */; };
 		99D17075DA3F00F52A18BB4D /* AccentColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17DD7EDA74DAAFA27C84CA08 /* AccentColor.swift */; };
 		9E75E4AEEF577E0096E22DBA /* ItemListView+Toolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03343E3D48A0EF2B146528E2 /* ItemListView+Toolbar.swift */; };
 		A0AA8FD4C542E9AEB2437BC2 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */; };
@@ -240,6 +241,7 @@
 		E8448C5778F75F52719114AF /* PlatformTextFieldWidthModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformTextFieldWidthModifier.swift; sourceTree = "<group>"; };
 		E85BFCBD4DCB35CC1C8F9401 /* ItemListView+PullGestures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ItemListView+PullGestures.swift"; sourceTree = "<group>"; };
 		E8B5E429B253183F887C5FD6 /* KeyCommandBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCommandBridge.swift; sourceTree = "<group>"; };
+		E8F7B7D46010E217FC0A5BFF /* TutorialSeeder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialSeeder.swift; sourceTree = "<group>"; };
 		E9288507CE6023425D1DE724 /* SyncDiagnosticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncDiagnosticsView.swift; sourceTree = "<group>"; };
 		EADAA53B8BCBC80AEFF191EF /* ListlessMacUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListlessMacUITests.swift; sourceTree = "<group>"; };
 		F15BF645DEDE8D9E94DB508B /* BackgroundClickMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundClickMonitor.swift; sourceTree = "<group>"; };
@@ -264,6 +266,7 @@
 				FA5D58EC1FBAA96E83A79445 /* PlatformScrollIndicatorsModifier.swift */,
 				E2174C43654733E9D4023157 /* PlatformTextFieldWidthModifier.swift */,
 				6ECE0E961F87BA32FA87BF90 /* TappableTextField.swift */,
+				E8F7B7D46010E217FC0A5BFF /* TutorialSeeder.swift */,
 			);
 			path = Helpers;
 			sourceTree = "<group>";
@@ -847,6 +850,7 @@
 				072594B24D88AD6D1DF7AFE5 /* SettingsView.swift in Sources */,
 				4DD2030E321567BD25661760 /* SyncDiagnosticsView.swift in Sources */,
 				0F12D56A528FCBF8A67864CB /* TappableTextField.swift in Sources */,
+				9853BD0C426C4F4348D08E8F /* TutorialSeeder.swift in Sources */,
 				1A66A0454558B207AF9265D4 /* UndoToast.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;

diff --git a/ListlessiOS/Extensions/ItemListView+NavigationHeader.swift b/ListlessiOS/Extensions/ItemListView+NavigationHeader.swift

@@ -1,6 +1,30 @@
 import SwiftUI
 
 extension ItemListView {
+    @ViewBuilder
+    private var finishButton: some View {
+        if #available(iOS 26.0, *) {
+            Button {
+                onFinishTutorial?()
+            } label: {
+                Text("Finish")
+                    .font(.body)
+                    .fontWeight(.semibold)
+                    .padding(.horizontal, 12)
+                    .padding(.vertical, 8)
+                    .glassEffect(.clear)
+            }
+            .tint(.secondary)
+        } else {
+            Button("Finish") {
+                onFinishTutorial?()
+            }
+            .font(.body)
+            .fontWeight(.semibold)
+            .foregroundStyle(.secondary)
+        }
+    }
+
     @ViewBuilder
     private var overflowMenu: some View {
         if #available(iOS 26.0, *) {
@@ -49,20 +73,24 @@ extension ItemListView {
 
     var navigationHeader: some View {
         HStack {
-            Text(listName)
+            Text(isTutorial ? "Tutorial" : listName)
                 .font(.largeTitle)
                 .fontWeight(.bold)
             Spacer()
-            if syncMonitor.hasDiagnosticsIssue {
-                Button {
-                    showSyncDiagnostics()
-                } label: {
-                    Image(systemName: "exclamationmark.icloud")
-                        .font(.title2)
-                        .foregroundStyle(.red)
+            if isTutorial {
+                finishButton
+            } else {
+                if syncMonitor.hasDiagnosticsIssue {
+                    Button {
+                        showSyncDiagnostics()
+                    } label: {
+                        Image(systemName: "exclamationmark.icloud")
+                            .font(.title2)
+                            .foregroundStyle(.red)
+                    }
                 }
+                overflowMenu
             }
-            overflowMenu
         }
         .padding(.horizontal, 16)
         .padding(.bottom, 8)

diff --git a/ListlessiOS/Helpers/TutorialSeeder.swift b/ListlessiOS/Helpers/TutorialSeeder.swift

@@ -0,0 +1,31 @@
+import Foundation
+
+@MainActor
+enum TutorialSeeder {
+    static func seed(store: ItemStore) {
+        let titles = [
+            "Swipe left to complete",
+            "Swipe right to delete",
+            "Long press and drag to reorder",
+            "Tap the text to edit",
+            "Pull down to create",
+            "Or tap below to create",
+            "Pull up to clear",
+        ]
+
+        for (index, title) in titles.enumerated() {
+            do {
+                _ = try store.createItem(
+                    title: title,
+                    sortOrder: Int64(index) * 1000
+                )
+            } catch {
+                continue
+            }
+        }
+
+        do {
+            try store.save()
+        } catch {}
+    }
+}

diff --git a/ListlessiOS/ListlessiOSApp.swift b/ListlessiOS/ListlessiOSApp.swift

@@ -62,44 +62,86 @@ class IOSAppDelegate: UIResponder, UIApplicationDelegate {
 struct ListlessiOSApp: App {
     @UIApplicationDelegateAdaptor(IOSAppDelegate.self) var appDelegate
     @AppStorage("appearanceMode") private var appearanceMode = 0
+    @AppStorage("didCompleteTutorial") private var didCompleteTutorial = false
     private let persistenceController: PersistenceController
+    private let tutorialPersistenceController = PersistenceController(inMemory: true)
     private let keyValueSyncBridge = KeyValueSyncBridge(keys: ["listName", "colorTheme"])
 
     init() {
         let isUITesting = ProcessInfo.processInfo.arguments.contains("UI_TESTING")
         persistenceController = isUITesting ? PersistenceController(inMemory: true) : .shared
         keyValueSyncBridge.start()
+
+        if isUITesting {
+            UserDefaults.standard.set(true, forKey: "didCompleteTutorial")
+        }
+
+        let tutorialStore = ItemStore(persistenceController: tutorialPersistenceController)
+        TutorialSeeder.seed(store: tutorialStore)
     }
 
     var body: some Scene {
         WindowGroup {
-            ItemListView(
-                store: ItemStore(persistenceController: persistenceController),
-                syncMonitor: persistenceController.syncMonitor
-            )
-                .safeAreaInset(edge: .top) {
-                    Color.clear.frame(height: 8)
-                }
-                .environment(\.managedObjectContext, persistenceController.viewContext)
-                .onChange(of: appearanceMode, initial: true) { _, newValue in
-                    let style: UIUserInterfaceStyle = switch newValue {
-                    case 1: .light
-                    case 2: .dark
-                    default: .unspecified
-                    }
-                    for scene in UIApplication.shared.connectedScenes {
-                        guard let windowScene = scene as? UIWindowScene else { continue }
-                        for window in windowScene.windows {
-                            window.overrideUserInterfaceStyle = style
-                        }
-                    }
-                }
-                .overlay(alignment: .top) {
-                    Color.outerBackground
-                        .opacity(0.9)
-                        .ignoresSafeArea(edges: .top)
-                        .frame(height: 0)
-                }
+            if didCompleteTutorial {
+                mainListView
+            } else {
+                tutorialListView
+            }
+        }
+    }
+
+    private var mainListView: some View {
+        ItemListView(
+            store: ItemStore(persistenceController: persistenceController),
+            syncMonitor: persistenceController.syncMonitor
+        )
+        .safeAreaInset(edge: .top) {
+            Color.clear.frame(height: 8)
+        }
+        .environment(\.managedObjectContext, persistenceController.viewContext)
+        .onChange(of: appearanceMode, initial: true) { _, newValue in
+            applyAppearanceMode(newValue)
+        }
+        .overlay(alignment: .top) {
+            Color.outerBackground
+                .opacity(0.9)
+                .ignoresSafeArea(edges: .top)
+                .frame(height: 0)
+        }
+    }
+
+    private var tutorialListView: some View {
+        ItemListView(
+            store: ItemStore(persistenceController: tutorialPersistenceController),
+            syncMonitor: tutorialPersistenceController.syncMonitor,
+            onFinishTutorial: { didCompleteTutorial = true }
+        )
+        .safeAreaInset(edge: .top) {
+            Color.clear.frame(height: 8)
+        }
+        .environment(\.managedObjectContext, tutorialPersistenceController.viewContext)
+        .onChange(of: appearanceMode, initial: true) { _, newValue in
+            applyAppearanceMode(newValue)
+        }
+        .overlay(alignment: .top) {
+            Color.outerBackground
+                .opacity(0.9)
+                .ignoresSafeArea(edges: .top)
+                .frame(height: 0)
+        }
+    }
+
+    private func applyAppearanceMode(_ mode: Int) {
+        let style: UIUserInterfaceStyle = switch mode {
+        case 1: .light
+        case 2: .dark
+        default: .unspecified
+        }
+        for scene in UIApplication.shared.connectedScenes {
+            guard let windowScene = scene as? UIWindowScene else { continue }
+            for window in windowScene.windows {
+                window.overrideUserInterfaceStyle = style
+            }
         }
     }
 }

diff --git a/ListlessiOS/Views/ItemListView.swift b/ListlessiOS/Views/ItemListView.swift

@@ -58,6 +58,7 @@ struct ItemListView: View, ItemListViewProtocol {
     @State var isDragging = false
     @State var layoutStorage = LayoutStorage()
     @State var scrollPosition = ScrollPosition()
+    @State private var showTutorialHint = false
 
     var focusedField: FocusField? {
         get { fState.focusedField }
@@ -153,9 +154,13 @@ struct ItemListView: View, ItemListViewProtocol {
     var flickThreshold: CGFloat { 500 }
     var isCompletelyEmpty: Bool { activeItems.isEmpty && completedItems.isEmpty }
 
-    init(store: ItemStore, syncMonitor: CloudKitSyncMonitor) {
+    var onFinishTutorial: (() -> Void)?
+    var isTutorial: Bool { onFinishTutorial != nil }
+
+    init(store: ItemStore, syncMonitor: CloudKitSyncMonitor, onFinishTutorial: (() -> Void)? = nil) {
         self.store = store
         self.syncMonitor = syncMonitor
+        self.onFinishTutorial = onFinishTutorial
     }
 
     func clearDraftItemUI(at placement: DraftItemPlacement, hasTitle: Bool) {
@@ -445,6 +450,36 @@ struct ItemListView: View, ItemListViewProtocol {
                 guard !Task.isCancelled else { return }
                 dismissUndoToast()
             }
+            .overlay(alignment: .top) {
+                if showTutorialHint {
+                    Text("Submit empty to remove")
+                        .font(.body)
+                        .foregroundStyle(.black)
+                        .padding(.horizontal, 20)
+                        .padding(.vertical, 14)
+                        .background(
+                            RoundedRectangle(cornerRadius: 12)
+                                .fill(Color(red: 1.0, green: 0.84, blue: 0.04))
+                        )
+                        .padding(.top, 24)
+                        .transition(.move(edge: .top).combined(with: .opacity))
+                }
+            }
+            .onChange(of: iState.draftPlacement) { _, newValue in
+                if isTutorial, newValue == .append {
+                    withAnimation {
+                        showTutorialHint = true
+                    }
+                }
+            }
+            .task(id: showTutorialHint) {
+                guard showTutorialHint else { return }
+                try? await Task.sleep(for: .seconds(4))
+                guard !Task.isCancelled else { return }
+                withAnimation {
+                    showTutorialHint = false
+                }
+            }
             .sheet(isPresented: $iState.isShowingSyncDiagnostics) {
                 NavigationStack {
                     SyncDiagnosticsView(syncMonitor: syncMonitor)

diff --git a/ListlessiOS/Views/SettingsView.swift b/ListlessiOS/Views/SettingsView.swift

@@ -8,6 +8,7 @@ struct SettingsView: View {
     @AppStorage("hapticsEnabled") private var hapticsEnabled = true
     @AppStorage("debugMode") private var debugMode = false
     @AppStorage("showFPSOverlay") private var showFPSOverlay = false
+    @AppStorage("didCompleteTutorial") private var didCompleteTutorial = false
     @State private var easterEggTaps = 0
 
     var body: some View {
@@ -64,6 +65,10 @@ struct SettingsView: View {
                         NavigationLink("iCloud Diagnostics") {
                             SyncDiagnosticsView(syncMonitor: syncMonitor)
                         }
+                        Button("Reset Tutorial") {
+                            didCompleteTutorial = false
+                            dismiss()
+                        }
                     }
                 }