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:
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
@@ -2,6 +2,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, *) {
Menu {
@@ -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()
+ }
}
}