commit 805c6cd40c41e5f7f513084c8f69b0332301a0bf
parent 5f7fc7b1bb683b22910a85f6ffe92ffeac74de69
Author: Michael Camilleri <[email protected]>
Date: Wed, 4 Feb 2026 12:10:51 +0900
Keyboard navigation working
Co-Authored-By: Claude 4.5 Sonnet <[email protected]>
Diffstat:
17 files changed, 824 insertions(+), 24 deletions(-)
diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj
@@ -8,22 +8,50 @@
/* Begin PBXBuildFile section */
0ACA67F6578EFF181EE5C9A7 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBB8A3BEB346267B30B4675F /* TaskItem.swift */; };
+ 3ABE52A15C2059D8D5570528 /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3DEE364304587D280C5672 /* TaskStore.swift */; };
+ 42E4CDE1D17463554CC4F41F /* TaskListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537A913AC421BAEF60D26D9C /* TaskListView.swift */; };
+ 5B60B409CE4BA668DB30A65D /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; };
614FCCA450EC0BFFD8B40640 /* ListlessMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA467DF2E59BDBE6EEF6A7D /* ListlessMacApp.swift */; };
+ 6C252050E62AED3A0A684EBF /* KeyboardNavigationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E7B37EF6A2389656D105FF8 /* KeyboardNavigationModifier.swift */; };
+ 8E08F4C82D6F9E67667CB20A /* TaskListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537A913AC421BAEF60D26D9C /* TaskListView.swift */; };
+ 8E680EC18B0339931E785F0C /* PlatformScrollIndicatorsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48AE1AE43296C1692FA6F755 /* PlatformScrollIndicatorsModifier.swift */; };
+ 9041B7CED5298439BF7DC2C1 /* TaskRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 007D500D2EFF3E66B780ADE0 /* TaskRowView.swift */; };
+ 91EDF52C7C5C0B35E9D8B51E /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3DEE364304587D280C5672 /* TaskStore.swift */; };
+ 96617677059FABDBB80D642B /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; };
+ A0AA8FD4C542E9AEB2437BC2 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */; };
+ A8D13DD6196772ECA146CF2A /* PlatformScrollIndicatorsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D74AE2501F35974F57D21F /* PlatformScrollIndicatorsModifier.swift */; };
C1FE091454864C4BBBBEB077 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBB8A3BEB346267B30B4675F /* TaskItem.swift */; };
+ C2400278D5F6F79C85A68897 /* TaskRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 007D500D2EFF3E66B780ADE0 /* TaskRowView.swift */; };
+ D878CD3A552C6A9685A30AA8 /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF6B0195EA0176B72DE9B092 /* PlatformTextFieldWidthModifier.swift */; };
+ D8EFF49E9156083D675D47F0 /* KeyboardNavigationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E7B37EF6A2389656D105FF8 /* KeyboardNavigationModifier.swift */; };
+ DC71C45D92524C3893BF9FDB /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81880970CCFC2CB00B65047E /* PlatformTextFieldWidthModifier.swift */; };
+ DC73A39A269AB495BCE1AC48 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */; };
F0B2B806BD84A4F2FDF8E038 /* ListlessiOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC245331D715EA85887C0BA0 /* ListlessiOSApp.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
+ 007D500D2EFF3E66B780ADE0 /* TaskRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowView.swift; sourceTree = "<group>"; };
01E141436176F83594E2F26B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
+ 0E7B37EF6A2389656D105FF8 /* KeyboardNavigationModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardNavigationModifier.swift; sourceTree = "<group>"; };
126108860D7878DDC3BECC4B /* Listless iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "Listless iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
1DA467DF2E59BDBE6EEF6A7D /* ListlessMacApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListlessMacApp.swift; sourceTree = "<group>"; };
3313FEDB101EECA4B344EEF4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
+ 48AE1AE43296C1692FA6F755 /* PlatformScrollIndicatorsModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformScrollIndicatorsModifier.swift; sourceTree = "<group>"; };
+ 537A913AC421BAEF60D26D9C /* TaskListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskListView.swift; sourceTree = "<group>"; };
5B0E22B8F7B2B7283CAF749E /* Listless macOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Listless macOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
74255E6B6C40899E9B17D927 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
+ 7C73E9D4C42CCABBF0F33543 /* Listless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Listless.entitlements; sourceTree = "<group>"; };
+ 81880970CCFC2CB00B65047E /* PlatformTextFieldWidthModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformTextFieldWidthModifier.swift; sourceTree = "<group>"; };
+ 9262207DAC21619BD9EDEE15 /* Listless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Listless.entitlements; sourceTree = "<group>"; };
944BAE054AAC1B9C4FC954F9 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
+ 9FBEB58DD41817F09B0EB9F0 /* Listless.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Listless.xcdatamodel; sourceTree = "<group>"; };
AC245331D715EA85887C0BA0 /* ListlessiOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListlessiOSApp.swift; sourceTree = "<group>"; };
+ B4D74AE2501F35974F57D21F /* PlatformScrollIndicatorsModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformScrollIndicatorsModifier.swift; sourceTree = "<group>"; };
+ C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
D10B5491A53E77C80F8F75CD /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
D123BB181208FC825777B0A7 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
+ DC3DEE364304587D280C5672 /* TaskStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStore.swift; sourceTree = "<group>"; };
+ EF6B0195EA0176B72DE9B092 /* PlatformTextFieldWidthModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformTextFieldWidthModifier.swift; sourceTree = "<group>"; };
FBB8A3BEB346267B30B4675F /* TaskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItem.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -32,6 +60,9 @@
isa = PBXGroup;
children = (
D123BB181208FC825777B0A7 /* .gitkeep */,
+ 0E7B37EF6A2389656D105FF8 /* KeyboardNavigationModifier.swift */,
+ 537A913AC421BAEF60D26D9C /* TaskListView.swift */,
+ 007D500D2EFF3E66B780ADE0 /* TaskRowView.swift */,
);
path = Views;
sourceTree = "<group>";
@@ -56,11 +87,22 @@
path = Listless;
sourceTree = "<group>";
};
+ 58F917D865E0BDF4EF282306 /* Views */ = {
+ isa = PBXGroup;
+ children = (
+ 48AE1AE43296C1692FA6F755 /* PlatformScrollIndicatorsModifier.swift */,
+ 81880970CCFC2CB00B65047E /* PlatformTextFieldWidthModifier.swift */,
+ );
+ path = Views;
+ sourceTree = "<group>";
+ };
8656EEF3161BE20196B8042E /* Models */ = {
isa = PBXGroup;
children = (
74255E6B6C40899E9B17D927 /* .gitkeep */,
+ C093494053E6C348F245D4EC /* Listless.xcdatamodeld */,
FBB8A3BEB346267B30B4675F /* TaskItem.swift */,
+ DC3DEE364304587D280C5672 /* TaskStore.swift */,
);
path = Models;
sourceTree = "<group>";
@@ -69,7 +111,9 @@
isa = PBXGroup;
children = (
3313FEDB101EECA4B344EEF4 /* Info.plist */,
+ 9262207DAC21619BD9EDEE15 /* Listless.entitlements */,
AC245331D715EA85887C0BA0 /* ListlessiOSApp.swift */,
+ 58F917D865E0BDF4EF282306 /* Views */,
);
path = ListlessiOS;
sourceTree = "<group>";
@@ -82,11 +126,22 @@
path = Infrastructure;
sourceTree = "<group>";
};
+ D12ECC901ABED96B86CC85B5 /* Views */ = {
+ isa = PBXGroup;
+ children = (
+ B4D74AE2501F35974F57D21F /* PlatformScrollIndicatorsModifier.swift */,
+ EF6B0195EA0176B72DE9B092 /* PlatformTextFieldWidthModifier.swift */,
+ );
+ path = Views;
+ sourceTree = "<group>";
+ };
D5B197AFF26144948D032299 /* ListlessMac */ = {
isa = PBXGroup;
children = (
01E141436176F83594E2F26B /* Info.plist */,
+ 7C73E9D4C42CCABBF0F33543 /* Listless.entitlements */,
1DA467DF2E59BDBE6EEF6A7D /* ListlessMacApp.swift */,
+ D12ECC901ABED96B86CC85B5 /* Views */,
);
path = ListlessMac;
sourceTree = "<group>";
@@ -95,6 +150,7 @@
isa = PBXGroup;
children = (
944BAE054AAC1B9C4FC954F9 /* .gitkeep */,
+ C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */,
);
path = Sync;
sourceTree = "<group>";
@@ -154,6 +210,16 @@
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1430;
+ TargetAttributes = {
+ 0FB4F07A37999BBC6DFE4DBB = {
+ DevelopmentTeam = 7TD7PZBNXP;
+ ProvisioningStyle = Automatic;
+ };
+ 34A03D42B91730DEAC2EBD8E = {
+ DevelopmentTeam = 7TD7PZBNXP;
+ ProvisioningStyle = Automatic;
+ };
+ };
};
buildConfigurationList = CAACA40A09D5F78ECE7A0EDF /* Build configuration list for PBXProject "Listless" */;
compatibilityVersion = "Xcode 14.0";
@@ -180,8 +246,16 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ D8EFF49E9156083D675D47F0 /* KeyboardNavigationModifier.swift in Sources */,
+ 96617677059FABDBB80D642B /* Listless.xcdatamodeld in Sources */,
614FCCA450EC0BFFD8B40640 /* ListlessMacApp.swift in Sources */,
+ A0AA8FD4C542E9AEB2437BC2 /* PersistenceController.swift in Sources */,
+ A8D13DD6196772ECA146CF2A /* PlatformScrollIndicatorsModifier.swift in Sources */,
+ D878CD3A552C6A9685A30AA8 /* PlatformTextFieldWidthModifier.swift in Sources */,
C1FE091454864C4BBBBEB077 /* TaskItem.swift in Sources */,
+ 8E08F4C82D6F9E67667CB20A /* TaskListView.swift in Sources */,
+ C2400278D5F6F79C85A68897 /* TaskRowView.swift in Sources */,
+ 3ABE52A15C2059D8D5570528 /* TaskStore.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -189,8 +263,16 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ 6C252050E62AED3A0A684EBF /* KeyboardNavigationModifier.swift in Sources */,
+ 5B60B409CE4BA668DB30A65D /* Listless.xcdatamodeld in Sources */,
F0B2B806BD84A4F2FDF8E038 /* ListlessiOSApp.swift in Sources */,
+ DC73A39A269AB495BCE1AC48 /* PersistenceController.swift in Sources */,
+ 8E680EC18B0339931E785F0C /* PlatformScrollIndicatorsModifier.swift in Sources */,
+ DC71C45D92524C3893BF9FDB /* PlatformTextFieldWidthModifier.swift in Sources */,
0ACA67F6578EFF181EE5C9A7 /* TaskItem.swift in Sources */,
+ 42E4CDE1D17463554CC4F41F /* TaskListView.swift in Sources */,
+ 9041B7CED5298439BF7DC2C1 /* TaskRowView.swift in Sources */,
+ 91EDF52C7C5C0B35E9D8B51E /* TaskStore.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -201,7 +283,10 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_ENTITLEMENTS = ListlessMac/Listless.entitlements;
+ CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
+ DEVELOPMENT_TEAM = 7TD7PZBNXP;
INFOPLIST_FILE = ListlessMac/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -216,7 +301,10 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_ENTITLEMENTS = ListlessMac/Listless.entitlements;
+ CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
+ DEVELOPMENT_TEAM = 7TD7PZBNXP;
INFOPLIST_FILE = ListlessMac/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -350,7 +438,10 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_ENTITLEMENTS = ListlessiOS/Listless.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
+ CODE_SIGN_STYLE = Automatic;
+ DEVELOPMENT_TEAM = 7TD7PZBNXP;
INFOPLIST_FILE = ListlessiOS/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -366,7 +457,10 @@
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_ENTITLEMENTS = ListlessiOS/Listless.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
+ CODE_SIGN_STYLE = Automatic;
+ DEVELOPMENT_TEAM = 7TD7PZBNXP;
INFOPLIST_FILE = ListlessiOS/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -409,6 +503,19 @@
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */
+
+/* Begin XCVersionGroup section */
+ C093494053E6C348F245D4EC /* Listless.xcdatamodeld */ = {
+ isa = XCVersionGroup;
+ children = (
+ 9FBEB58DD41817F09B0EB9F0 /* Listless.xcdatamodel */,
+ );
+ currentVersion = 9FBEB58DD41817F09B0EB9F0 /* Listless.xcdatamodel */;
+ path = Listless.xcdatamodeld;
+ sourceTree = "<group>";
+ versionGroupType = wrapper.xcdatamodel;
+ };
+/* End XCVersionGroup section */
};
rootObject = 3256C2BF8F1DAF371DA32120 /* Project object */;
}
diff --git a/Listless/Models/Listless.xcdatamodeld/.xccurrentversion b/Listless/Models/Listless.xcdatamodeld/.xccurrentversion
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>_XCCurrentVersionName</key>
+ <string>Listless.xcdatamodel</string>
+</dict>
+</plist>
diff --git a/Listless/Models/Listless.xcdatamodeld/Listless.xcdatamodel/contents b/Listless/Models/Listless.xcdatamodeld/Listless.xcdatamodel/contents
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="24512" systemVersion="24G419" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES" userDefinedModelVersionIdentifier="">
+ <entity name="TaskItem" representedClassName="TaskItem" syncable="YES">
+ <attribute name="createdAt" attributeType="Date" optional="YES" usesScalarValueType="NO"/>
+ <attribute name="id" attributeType="UUID" optional="YES" usesScalarValueType="NO"/>
+ <attribute name="isCompleted" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
+ <attribute name="title" attributeType="String" defaultValueString=""/>
+ <attribute name="updatedAt" attributeType="Date" optional="YES" usesScalarValueType="NO"/>
+ </entity>
+</model>
diff --git a/Listless/Models/TaskItem.swift b/Listless/Models/TaskItem.swift
@@ -1,13 +1,31 @@
import Foundation
+import CoreData
-public struct TaskItem: Identifiable, Codable {
- public let id: UUID
- public var title: String
- public var isCompleted: Bool
+@objc(TaskItem)
+public class TaskItem: NSManagedObject, Identifiable {
+ @NSManaged public var id: UUID
+ @NSManaged public var title: String
+ @NSManaged public var isCompleted: Bool
+ @NSManaged public var createdAt: Date
+ @NSManaged public var updatedAt: Date
- public init(id: UUID = UUID(), title: String, isCompleted: Bool = false) {
- self.id = id
- self.title = title
- self.isCompleted = isCompleted
+ @nonobjc public class func fetchRequest() -> NSFetchRequest<TaskItem> {
+ return NSFetchRequest<TaskItem>(entityName: "TaskItem")
+ }
+
+ public override func awakeFromInsert() {
+ super.awakeFromInsert()
+ setPrimitiveValue(UUID(), forKey: "id")
+ setPrimitiveValue(Date(), forKey: "createdAt")
+ setPrimitiveValue(Date(), forKey: "updatedAt")
+ setPrimitiveValue(false, forKey: "isCompleted")
+ setPrimitiveValue("", forKey: "title")
+ }
+
+ public override func willSave() {
+ super.willSave()
+ if !isDeleted && changedValues().keys.contains(where: { $0 != "updatedAt" }) {
+ setPrimitiveValue(Date(), forKey: "updatedAt")
+ }
}
}
diff --git a/Listless/Models/TaskStore.swift b/Listless/Models/TaskStore.swift
@@ -0,0 +1,79 @@
+import Foundation
+import CoreData
+import Observation
+
+@MainActor
+@Observable
+final class TaskStore {
+ private let persistenceController: PersistenceController
+ private var context: NSManagedObjectContext {
+ persistenceController.viewContext
+ }
+
+ init(persistenceController: PersistenceController = .shared) {
+ self.persistenceController = persistenceController
+ }
+
+ func fetchTasks() -> [TaskItem] {
+ let request = TaskItem.fetchRequest()
+ request.sortDescriptors = [
+ NSSortDescriptor(keyPath: \TaskItem.isCompleted, ascending: true),
+ NSSortDescriptor(keyPath: \TaskItem.createdAt, ascending: true),
+ ]
+
+ do {
+ return try context.fetch(request)
+ } catch {
+ print("Failed to fetch tasks: \(error)")
+ return []
+ }
+ }
+
+ func createTask(title: String = "") -> TaskItem {
+ let task = TaskItem(context: context)
+ task.title = title
+ save()
+ return task
+ }
+
+ func complete(taskID: UUID) {
+ guard let task = findTask(id: taskID) else { return }
+ task.isCompleted = true
+ save()
+ }
+
+ func uncomplete(taskID: UUID) {
+ guard let task = findTask(id: taskID) else { return }
+ task.isCompleted = false
+ save()
+ }
+
+ func update(taskID: UUID, title: String) {
+ guard let task = findTask(id: taskID) else { return }
+ task.title = title
+ save()
+ }
+
+ func delete(taskID: UUID) {
+ guard let task = findTask(id: taskID) else { return }
+ context.delete(task)
+ save()
+ }
+
+ private func findTask(id: UUID) -> TaskItem? {
+ let request = TaskItem.fetchRequest()
+ request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
+ request.fetchLimit = 1
+
+ do {
+ return try context.fetch(request).first
+ } catch {
+ print("Failed to find task: \(error)")
+ return nil
+ }
+ }
+
+ private func save() {
+ persistenceController.save()
+ }
+}
diff --git a/Listless/Sync/PersistenceController.swift b/Listless/Sync/PersistenceController.swift
@@ -0,0 +1,55 @@
+import CoreData
+import Foundation
+
+@MainActor
+final class PersistenceController {
+ static let shared = PersistenceController()
+
+ let container: NSPersistentCloudKitContainer
+
+ var viewContext: NSManagedObjectContext {
+ container.viewContext
+ }
+
+ init(inMemory: Bool = false) {
+ container = NSPersistentCloudKitContainer(name: "Listless")
+
+ if inMemory {
+ container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
+ } else {
+ // Configure CloudKit sync
+ guard let description = container.persistentStoreDescriptions.first else {
+ fatalError("Failed to retrieve persistent store description")
+ }
+
+ description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
+ containerIdentifier: "iCloud.net.inqk.listless"
+ )
+
+ description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
+ description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
+ }
+
+ container.loadPersistentStores { storeDescription, error in
+ if let error = error as NSError? {
+ fatalError("Unresolved error \(error), \(error.userInfo)")
+ }
+ }
+
+ container.viewContext.automaticallyMergesChangesFromParent = true
+ container.viewContext.mergePolicy = NSMergePolicy(merge: .mergeByPropertyObjectTrumpMergePolicyType)
+ }
+
+ func save() {
+ let context = container.viewContext
+
+ if context.hasChanges {
+ do {
+ try context.save()
+ } catch {
+ let nsError = error as NSError
+ fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
+ }
+ }
+ }
+}
diff --git a/Listless/Views/KeyboardNavigationModifier.swift b/Listless/Views/KeyboardNavigationModifier.swift
@@ -0,0 +1,20 @@
+import SwiftUI
+
+extension View {
+ func keyboardNavigation(
+ onUpArrow: @escaping () -> Void,
+ onDownArrow: @escaping () -> Void
+ ) -> some View {
+ self
+ .onKeyPress(.upArrow) {
+ print("KeyboardNavigation: upArrow pressed")
+ onUpArrow()
+ return .handled
+ }
+ .onKeyPress(.downArrow) {
+ print("KeyboardNavigation: downArrow pressed")
+ onDownArrow()
+ return .handled
+ }
+ }
+}
diff --git a/Listless/Views/TaskListView.swift b/Listless/Views/TaskListView.swift
@@ -0,0 +1,224 @@
+import SwiftUI
+
+struct TaskListView: View {
+ enum FocusField: Hashable {
+ case task(UUID)
+ }
+
+ @State private var store: TaskStore
+ @State private var tasks: [TaskItem] = []
+ @FocusState private var focusedField: FocusField?
+ @FocusState private var scrollViewFocused: Bool
+ @State private var selectedTaskID: UUID?
+ @State private var refreshID = UUID()
+
+ init(store: TaskStore = TaskStore()) {
+ _store = State(wrappedValue: store)
+ }
+
+ var body: some View {
+ ScrollView {
+ ScrollViewReader { proxy in
+ VStack(alignment: .leading, spacing: 0) {
+ if !completedTasks.isEmpty {
+ Text("Completed")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .padding(.leading, 16)
+ .padding(.bottom, 6)
+ }
+
+ ForEach(completedTasks) { task in
+ TaskRowView(
+ task: task,
+ taskID: task.id,
+ isSelected: selectedTaskID == task.id,
+ focusedField: $focusedField,
+ onToggle: toggleCompletion(_:),
+ onSubmit: handleSubmit(_:),
+ onTitleChange: updateTitle(_:_:),
+ onDelete: deleteTask(_:),
+ onSelect: { selectTask(task.id) }
+ )
+ }
+
+ if !activeTasks.isEmpty && !completedTasks.isEmpty {
+ Divider()
+ .padding(.vertical, 8)
+ }
+
+ ForEach(activeTasks) { task in
+ TaskRowView(
+ task: task,
+ taskID: task.id,
+ isSelected: selectedTaskID == task.id,
+ focusedField: $focusedField,
+ onToggle: toggleCompletion(_:),
+ onSubmit: handleSubmit(_:),
+ onTitleChange: updateTitle(_:_:),
+ onDelete: deleteTask(_:),
+ onSelect: { selectTask(task.id) }
+ )
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .topLeading)
+ .onChange(of: selectedTaskID) { oldValue, newValue in
+ if let newValue {
+ withAnimation(.easeInOut(duration: 0.2)) {
+ proxy.scrollTo(newValue, anchor: .center)
+ }
+ }
+ }
+ }
+ }
+ .contentShape(Rectangle())
+ .onTapGesture {
+ print("TaskListView: background tap")
+ handleBackgroundTap()
+ }
+ .focusable()
+ .focused($scrollViewFocused)
+ .focusEffectDisabled()
+ .keyboardNavigation(
+ onUpArrow: navigateUp,
+ onDownArrow: navigateDown
+ )
+ .onAppear {
+ reloadTasks()
+ scrollViewFocused = true
+ }
+ .onChange(of: focusedField) { oldValue, newValue in
+ handleFocusChange(from: oldValue, to: newValue)
+ // When a text field gets focus, remove ScrollView focus
+ // When text field loses focus, restore ScrollView focus
+ scrollViewFocused = (newValue == nil)
+ }
+ }
+
+ private var activeTasks: [TaskItem] {
+ tasks.filter { !$0.isCompleted }
+ }
+
+ private var completedTasks: [TaskItem] {
+ tasks.filter { $0.isCompleted }
+ }
+
+ private var allTasksInDisplayOrder: [TaskItem] {
+ completedTasks + activeTasks
+ }
+
+ private func reloadTasks() {
+ tasks = store.fetchTasks()
+ }
+
+ private func createTaskAndFocus() {
+ let task = store.createTask(title: "")
+ reloadTasks()
+ selectedTaskID = task.id
+ focusedField = .task(task.id)
+ }
+
+ private func handleBackgroundTap() {
+ if focusedField != nil || selectedTaskID != nil {
+ focusedField = nil
+ selectedTaskID = nil
+ } else {
+ createTaskAndFocus()
+ }
+ }
+
+ private func handleFocusChange(from oldValue: FocusField?, to newValue: FocusField?) {
+ let oldID = taskID(from: oldValue)
+ let newID = taskID(from: newValue)
+
+ guard oldID != newID, let oldID else { return }
+ deleteIfEmpty(taskID: oldID)
+ }
+
+ private func taskID(from field: FocusField?) -> UUID? {
+ guard case let .task(id) = field else { return nil }
+ return id
+ }
+
+ private func deleteIfEmpty(taskID: UUID) {
+ guard let task = tasks.first(where: { $0.id == taskID }) else { return }
+ let trimmedTitle = task.title.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard trimmedTitle.isEmpty else { return }
+ deleteTask(task)
+ }
+
+ private func handleSubmit(_ task: TaskItem) {
+ createTaskAndFocus()
+ }
+
+ private func updateTitle(_ task: TaskItem, _ title: String) {
+ guard task.title != title else { return }
+ store.update(taskID: task.id, title: title)
+ }
+
+ private func toggleCompletion(_ task: TaskItem) {
+ if task.isCompleted {
+ store.uncomplete(taskID: task.id)
+ } else {
+ store.complete(taskID: task.id)
+ }
+ reloadTasks()
+ refreshID = UUID()
+ }
+
+ private func selectTask(_ taskID: UUID) {
+ selectedTaskID = taskID
+ }
+
+ private func deleteTask(_ task: TaskItem) {
+ let taskID = task.id
+ if focusedField == .task(taskID) {
+ focusedField = nil
+ }
+ if selectedTaskID == taskID {
+ selectedTaskID = nil
+ }
+ store.delete(taskID: taskID)
+ reloadTasks()
+ }
+
+ private func navigateUp() {
+ print("TaskListView: navigateUp called, selectedTaskID: \(String(describing: selectedTaskID))")
+ guard let currentID = selectedTaskID else {
+ selectedTaskID = activeTasks.last?.id
+ print("TaskListView: No selection, selected last active: \(String(describing: selectedTaskID))")
+ return
+ }
+
+ let displayOrder = allTasksInDisplayOrder
+ guard let currentIndex = displayOrder.firstIndex(where: { $0.id == currentID }) else {
+ print("TaskListView: Current task not found in display order")
+ return
+ }
+
+ if currentIndex > 0 {
+ selectedTaskID = displayOrder[currentIndex - 1].id
+ print("TaskListView: Moved to previous task: \(String(describing: selectedTaskID))")
+ }
+ }
+
+ private func navigateDown() {
+ print("TaskListView: navigateDown called, selectedTaskID: \(String(describing: selectedTaskID))")
+ guard let currentID = selectedTaskID else {
+ selectedTaskID = completedTasks.first?.id ?? activeTasks.first?.id
+ print("TaskListView: No selection, selected first: \(String(describing: selectedTaskID))")
+ return
+ }
+
+ let displayOrder = allTasksInDisplayOrder
+ guard let currentIndex = displayOrder.firstIndex(where: { $0.id == currentID }) else {
+ print("TaskListView: Current task not found in display order")
+ return
+ }
+
+ if currentIndex < displayOrder.count - 1 {
+ selectedTaskID = displayOrder[currentIndex + 1].id
+ print("TaskListView: Moved to next task: \(String(describing: selectedTaskID))")
+ }
+ }
+}
diff --git a/Listless/Views/TaskRowView.swift b/Listless/Views/TaskRowView.swift
@@ -0,0 +1,147 @@
+import SwiftUI
+
+struct TaskRowView: View {
+ let task: TaskItem
+ let taskID: UUID
+ let isSelected: Bool
+ let onToggle: (TaskItem) -> Void
+ let onSubmit: (TaskItem) -> Void
+ let onTitleChange: (TaskItem, String) -> Void
+ let onDelete: (TaskItem) -> Void
+ let onSelect: () -> Void
+ @FocusState.Binding var focusedField: TaskListView.FocusField?
+
+ @State private var title: String
+
+ init(
+ task: TaskItem,
+ taskID: UUID,
+ isSelected: Bool,
+ focusedField: FocusState<TaskListView.FocusField?>.Binding,
+ onToggle: @escaping (TaskItem) -> Void,
+ onSubmit: @escaping (TaskItem) -> Void,
+ onTitleChange: @escaping (TaskItem, String) -> Void,
+ onDelete: @escaping (TaskItem) -> Void,
+ onSelect: @escaping () -> Void
+ ) {
+ self.task = task
+ self.taskID = taskID
+ self.isSelected = isSelected
+ self.onToggle = onToggle
+ self.onSubmit = onSubmit
+ self.onTitleChange = onTitleChange
+ self.onDelete = onDelete
+ self.onSelect = onSelect
+ _focusedField = focusedField
+ _title = State(initialValue: task.title)
+ }
+
+ var body: some View {
+ HStack(spacing: 12) {
+ Button {
+ onToggle(task)
+ } label: {
+ Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
+ .foregroundStyle(task.isCompleted ? .secondary : .primary)
+ .frame(width: 20, height: 20)
+ }
+ .buttonStyle(.borderless)
+
+ TextField("New task", text: $title)
+ .textFieldStyle(.plain)
+ .font(.body)
+ .focused($focusedField, equals: .task(taskID))
+ .onSubmit {
+ onSubmit(task)
+ }
+ .platformTextFieldWidth(text: title, placeholder: "New task")
+ .disabled(task.isCompleted)
+ .strikethrough(task.isCompleted, color: .secondary)
+ .foregroundStyle(task.isCompleted ? .secondary : .primary)
+ }
+ .padding(.vertical, 8)
+ .padding(.horizontal, 16)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ print("TaskRowView: row tap \(taskID)")
+ onSelect()
+ focusedField = nil
+ }
+ .background(selectionBackground)
+ .contextMenu {
+ Button(task.isCompleted ? "Mark as Incomplete" : "Mark as Complete") {
+ onToggle(task)
+ }
+ Divider()
+ Button("Cut") {
+ cutToPasteboard()
+ }
+ Button("Copy") {
+ copyToPasteboard()
+ }
+ Button("Paste") {
+ pasteFromPasteboard()
+ }
+ Divider()
+ Button("Delete", role: .destructive) {
+ onDelete(task)
+ }
+ }
+ .onChange(of: title) {
+ guard !task.isCompleted else { return }
+ onTitleChange(task, title)
+ }
+ .onChange(of: task.title) {
+ if task.title != title {
+ title = task.title
+ }
+ }
+ .onChange(of: focusedField) { oldValue, newValue in
+ if newValue == .task(taskID) && oldValue != .task(taskID) {
+ onSelect()
+ }
+ }
+ }
+
+ private var selectionBackground: some View {
+ Group {
+ if isSelected {
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .fill(selectionFill)
+ } else {
+ Color.clear
+ }
+ }
+ }
+
+ private var selectionFill: Color {
+ Color.accentColor.opacity(0.2)
+ }
+
+ private func cutToPasteboard() {
+ copyToPasteboard()
+ onDelete(task)
+ }
+
+ private func copyToPasteboard() {
+ guard !title.isEmpty else { return }
+ #if os(macOS)
+ let pasteboard = NSPasteboard.general
+ pasteboard.clearContents()
+ pasteboard.setString(title, forType: .string)
+ #else
+ UIPasteboard.general.string = title
+ #endif
+ }
+
+ private func pasteFromPasteboard() {
+ #if os(macOS)
+ guard let string = NSPasteboard.general.string(forType: .string) else { return }
+ #else
+ guard let string = UIPasteboard.general.string else { return }
+ #endif
+ title = string
+ onTitleChange(task, string)
+ }
+}
diff --git a/ListlessMac/Listless.entitlements b/ListlessMac/Listless.entitlements
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>aps-environment</key>
+ <string>development</string>
+ <key>com.apple.developer.icloud-container-identifiers</key>
+ <array>
+ <string>iCloud.net.inqk.listless</string>
+ </array>
+ <key>com.apple.developer.icloud-services</key>
+ <array>
+ <string>CloudKit</string>
+ </array>
+ <key>com.apple.developer.ubiquity-container-identifiers</key>
+ <array>
+ <string>iCloud.net.inqk.listless</string>
+ </array>
+</dict>
+</plist>
diff --git a/ListlessMac/ListlessMacApp.swift b/ListlessMac/ListlessMacApp.swift
@@ -2,16 +2,11 @@ import SwiftUI
@main
struct ListlessMacApp: App {
+ private let persistenceController = PersistenceController.shared
+
var body: some Scene {
WindowGroup {
- ContentView()
+ TaskListView(store: TaskStore(persistenceController: persistenceController))
}
}
}
-
-struct ContentView: View {
- var body: some View {
- Text("Listless for macOS")
- .padding()
- }
-}
diff --git a/ListlessMac/Views/PlatformScrollIndicatorsModifier.swift b/ListlessMac/Views/PlatformScrollIndicatorsModifier.swift
@@ -0,0 +1,38 @@
+import SwiftUI
+
+private struct ContentHeightKey: PreferenceKey {
+ static let defaultValue: CGFloat = 0
+
+ static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
+ value = max(value, nextValue())
+ }
+}
+
+private struct PlatformScrollIndicatorsModifier: ViewModifier {
+ let verticalPadding: CGFloat
+ @State private var contentHeight: CGFloat = 0
+
+ func body(content: Content) -> some View {
+ GeometryReader { proxy in
+ let effectiveContentHeight = max(0, contentHeight - (verticalPadding * 2))
+ content
+ .background(
+ GeometryReader { contentProxy in
+ Color.clear.preference(key: ContentHeightKey.self, value: contentProxy.size.height)
+ }
+ )
+ .scrollIndicators(effectiveContentHeight > proxy.size.height ? .visible : .hidden)
+ .onPreferenceChange(ContentHeightKey.self) { height in
+ if height != contentHeight {
+ contentHeight = height
+ }
+ }
+ }
+ }
+}
+
+extension View {
+ func platformScrollIndicators(verticalPadding: CGFloat = 0) -> some View {
+ modifier(PlatformScrollIndicatorsModifier(verticalPadding: verticalPadding))
+ }
+}
diff --git a/ListlessMac/Views/PlatformTextFieldWidthModifier.swift b/ListlessMac/Views/PlatformTextFieldWidthModifier.swift
@@ -0,0 +1,50 @@
+import SwiftUI
+
+private struct TextFieldWidthPreferenceKey: PreferenceKey {
+ static let defaultValue: CGFloat = 0
+
+ static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
+ value = max(value, nextValue())
+ }
+}
+
+private struct MacTextFieldWidthModifier: ViewModifier {
+ let text: String
+ let placeholder: String
+
+ @State private var measuredWidth: CGFloat = 60
+
+ func body(content: Content) -> some View {
+ content
+ .frame(width: max(44, measuredWidth + 12), alignment: .leading)
+ .background(widthMeasurer)
+ .onPreferenceChange(TextFieldWidthPreferenceKey.self) { width in
+ if width > 0, width != measuredWidth {
+ measuredWidth = width
+ }
+ }
+ }
+
+ private var widthMeasurer: some View {
+ Text(displayText)
+ .font(.body)
+ .lineLimit(1)
+ .fixedSize()
+ .background(
+ GeometryReader { proxy in
+ Color.clear.preference(key: TextFieldWidthPreferenceKey.self, value: proxy.size.width)
+ }
+ )
+ .hidden()
+ }
+
+ private var displayText: String {
+ text.isEmpty ? placeholder : text
+ }
+}
+
+extension View {
+ func platformTextFieldWidth(text: String, placeholder: String) -> some View {
+ modifier(MacTextFieldWidthModifier(text: text, placeholder: placeholder))
+ }
+}
diff --git a/ListlessiOS/Listless.entitlements b/ListlessiOS/Listless.entitlements
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>aps-environment</key>
+ <string>development</string>
+ <key>com.apple.developer.icloud-container-identifiers</key>
+ <array>
+ <string>iCloud.net.inqk.listless</string>
+ </array>
+ <key>com.apple.developer.icloud-services</key>
+ <array>
+ <string>CloudKit</string>
+ </array>
+ <key>com.apple.developer.ubiquity-container-identifiers</key>
+ <array>
+ <string>iCloud.net.inqk.listless</string>
+ </array>
+</dict>
+</plist>
diff --git a/ListlessiOS/ListlessiOSApp.swift b/ListlessiOS/ListlessiOSApp.swift
@@ -2,16 +2,11 @@ import SwiftUI
@main
struct ListlessiOSApp: App {
+ private let persistenceController = PersistenceController.shared
+
var body: some Scene {
WindowGroup {
- ContentView()
+ TaskListView(store: TaskStore(persistenceController: persistenceController))
}
}
}
-
-struct ContentView: View {
- var body: some View {
- Text("Listless for iOS")
- .padding()
- }
-}
diff --git a/ListlessiOS/Views/PlatformScrollIndicatorsModifier.swift b/ListlessiOS/Views/PlatformScrollIndicatorsModifier.swift
@@ -0,0 +1,7 @@
+import SwiftUI
+
+extension View {
+ func platformScrollIndicators(verticalPadding: CGFloat = 0) -> some View {
+ self
+ }
+}
diff --git a/ListlessiOS/Views/PlatformTextFieldWidthModifier.swift b/ListlessiOS/Views/PlatformTextFieldWidthModifier.swift
@@ -0,0 +1,7 @@
+import SwiftUI
+
+extension View {
+ func platformTextFieldWidth(text: String, placeholder: String) -> some View {
+ self
+ }
+}