listless

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

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:
MListless.xcodeproj/project.pbxproj | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AListless/Models/Listless.xcdatamodeld/.xccurrentversion | 8++++++++
AListless/Models/Listless.xcdatamodeld/Listless.xcdatamodel/contents | 10++++++++++
MListless/Models/TaskItem.swift | 34++++++++++++++++++++++++++--------
AListless/Models/TaskStore.swift | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AListless/Sync/PersistenceController.swift | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
AListless/Views/KeyboardNavigationModifier.swift | 20++++++++++++++++++++
AListless/Views/TaskListView.swift | 224+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AListless/Views/TaskRowView.swift | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AListlessMac/Listless.entitlements | 20++++++++++++++++++++
MListlessMac/ListlessMacApp.swift | 11+++--------
AListlessMac/Views/PlatformScrollIndicatorsModifier.swift | 38++++++++++++++++++++++++++++++++++++++
AListlessMac/Views/PlatformTextFieldWidthModifier.swift | 50++++++++++++++++++++++++++++++++++++++++++++++++++
AListlessiOS/Listless.entitlements | 20++++++++++++++++++++
MListlessiOS/ListlessiOSApp.swift | 11+++--------
AListlessiOS/Views/PlatformScrollIndicatorsModifier.swift | 7+++++++
AListlessiOS/Views/PlatformTextFieldWidthModifier.swift | 7+++++++
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 + } +}