listless

A simple list app for Apple platforms
Log | Files | Refs | README | LICENSE

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 @@ -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() + } } }