feat: add macOS task board widget app
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
.DS_Store
|
||||||
|
DerivedData/
|
||||||
|
build/
|
||||||
|
xcuserdata/
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# UTrello
|
||||||
|
|
||||||
|
一个运行在 macOS 桌面的本地 Trello 风格任务看板,附带 WidgetKit 小组件。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- 三列任务看板:待办、进行中、完成
|
||||||
|
- 新增、移动和删除任务
|
||||||
|
- 桌面小组件显示未完成任务及其数量
|
||||||
|
- 任务数据只保存在本机;主应用和 Widget 通过 App Group 共享数据
|
||||||
|
- 主应用更新任务后,主动请求系统刷新小组件
|
||||||
|
|
||||||
|
## 技术要求
|
||||||
|
|
||||||
|
- macOS 14 Sonoma 或更高版本
|
||||||
|
- Xcode 15 或更高版本
|
||||||
|
- 可用于代码签名的 Apple 开发团队
|
||||||
|
|
||||||
|
## 本地运行
|
||||||
|
|
||||||
|
1. 在 Mac 上用 Xcode 打开 `UTrello.xcodeproj`。
|
||||||
|
2. 选择 `UTrello` target,在 **Signing & Capabilities** 中选择开发团队。
|
||||||
|
3. 对 `UTrelloWidget` target 重复上一步。
|
||||||
|
4. 在两个 target 的 App Groups capability 中,将 `group.com.example.utrello` 改为该开发团队已注册的 App Group 标识。
|
||||||
|
5. 将 [TaskStore.swift](UTrello/Shared/TaskStore.swift) 中的 `groupIdentifier` 改为同一个标识。
|
||||||
|
6. 选择 `UTrello` scheme 后运行。首次运行成功后,在 macOS 的组件库中将 **UTrello Tasks** 添加到桌面。
|
||||||
|
|
||||||
|
## 数据与刷新
|
||||||
|
|
||||||
|
任务以 JSON 形式写入 App Group 的 `UserDefaults`。Widget 会在任务变更时请求刷新,并会由系统每 15 分钟兜底刷新一次;实际刷新时机仍由 macOS 调度。
|
||||||
|
|
||||||
|
## 当前限制
|
||||||
|
|
||||||
|
- 这是纯本地应用,不会同步真实 Trello 服务。
|
||||||
|
- Widget 以浏览为主,复杂操作在主应用内完成。
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {};
|
||||||
|
objectVersion = 56;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
A10000000000000000000001 /* UTrelloApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000011 /* UTrelloApp.swift */; };
|
||||||
|
A10000000000000000000002 /* BoardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000012 /* BoardView.swift */; };
|
||||||
|
A10000000000000000000003 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000013 /* TaskItem.swift */; };
|
||||||
|
A10000000000000000000004 /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000014 /* TaskStore.swift */; };
|
||||||
|
A10000000000000000000005 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A10000000000000000000021 /* WidgetKit.framework */; };
|
||||||
|
A10000000000000000000006 /* UTrelloWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000031 /* UTrelloWidget.swift */; };
|
||||||
|
A10000000000000000000007 /* UTrelloWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000032 /* UTrelloWidgetBundle.swift */; };
|
||||||
|
A10000000000000000000008 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000013 /* TaskItem.swift */; };
|
||||||
|
A10000000000000000000009 /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000014 /* TaskStore.swift */; };
|
||||||
|
A1000000000000000000000A /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A10000000000000000000021 /* WidgetKit.framework */; };
|
||||||
|
A1000000000000000000000B /* UTrelloWidget.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = A10000000000000000000041 /* UTrelloWidget.appex */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
A10000000000000000000051 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = A10000000000000000000061 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = A10000000000000000000071;
|
||||||
|
remoteInfo = UTrelloWidget;
|
||||||
|
};
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
|
A10000000000000000000081 /* Embed App Extensions */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
dstPath = "";
|
||||||
|
dstSubfolderSpec = 13;
|
||||||
|
files = (A1000000000000000000000B /* UTrelloWidget.appex in Embed App Extensions */, );
|
||||||
|
name = "Embed App Extensions";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
A10000000000000000000011 /* UTrelloApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTrelloApp.swift; sourceTree = "<group>"; };
|
||||||
|
A10000000000000000000012 /* BoardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoardView.swift; sourceTree = "<group>"; };
|
||||||
|
A10000000000000000000013 /* TaskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItem.swift; sourceTree = "<group>"; };
|
||||||
|
A10000000000000000000014 /* TaskStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStore.swift; sourceTree = "<group>"; };
|
||||||
|
A10000000000000000000015 /* UTrello.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = UTrello.entitlements; sourceTree = "<group>"; };
|
||||||
|
A10000000000000000000021 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
||||||
|
A10000000000000000000031 /* UTrelloWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTrelloWidget.swift; sourceTree = "<group>"; };
|
||||||
|
A10000000000000000000032 /* UTrelloWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UTrelloWidgetBundle.swift; sourceTree = "<group>"; };
|
||||||
|
A10000000000000000000033 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
A10000000000000000000034 /* UTrelloWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = UTrelloWidget.entitlements; sourceTree = "<group>"; };
|
||||||
|
A10000000000000000000040 /* UTrello.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = UTrello.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
A10000000000000000000041 /* UTrelloWidget.appex */ = {isa = PBXFileReference; explicitFileType = wrapper.app-extension; includeInIndex = 0; path = UTrelloWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
A10000000000000000000091 /* Frameworks */ = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (A10000000000000000000005 /* WidgetKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; };
|
||||||
|
A10000000000000000000092 /* Frameworks */ = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (A1000000000000000000000A /* WidgetKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; };
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
A100000000000000000000A1 = {isa = PBXGroup; children = (A100000000000000000000A2 /* UTrello */, A100000000000000000000A5 /* UTrelloWidget */, A100000000000000000000A8 /* Frameworks */, A100000000000000000000A9 /* Products */, ); sourceTree = "<group>"; };
|
||||||
|
A100000000000000000000A2 /* UTrello */ = {isa = PBXGroup; path = UTrello; sourceTree = "<group>"; children = (A100000000000000000000A3 /* App */, A100000000000000000000A4 /* Shared */, A10000000000000000000015 /* UTrello.entitlements */, ); };
|
||||||
|
A100000000000000000000A3 /* App */ = {isa = PBXGroup; path = App; sourceTree = "<group>"; children = (A10000000000000000000011 /* UTrelloApp.swift */, A10000000000000000000012 /* BoardView.swift */, ); };
|
||||||
|
A100000000000000000000A4 /* Shared */ = {isa = PBXGroup; path = Shared; sourceTree = "<group>"; children = (A10000000000000000000013 /* TaskItem.swift */, A10000000000000000000014 /* TaskStore.swift */, ); };
|
||||||
|
A100000000000000000000A5 /* UTrelloWidget */ = {isa = PBXGroup; path = UTrelloWidget; sourceTree = "<group>"; children = (A10000000000000000000031 /* UTrelloWidget.swift */, A10000000000000000000032 /* UTrelloWidgetBundle.swift */, A10000000000000000000033 /* Info.plist */, A10000000000000000000034 /* UTrelloWidget.entitlements */, ); };
|
||||||
|
A100000000000000000000A8 /* Frameworks */ = {isa = PBXGroup; children = (A10000000000000000000021 /* WidgetKit.framework */, ); name = Frameworks; sourceTree = "<group>"; };
|
||||||
|
A100000000000000000000A9 /* Products */ = {isa = PBXGroup; children = (A10000000000000000000040 /* UTrello.app */, A10000000000000000000041 /* UTrelloWidget.appex */, ); name = Products; sourceTree = "<group>"; };
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
A10000000000000000000070 /* UTrello */ = {isa = PBXNativeTarget; buildConfigurationList = A100000000000000000000B1 /* Build configuration list for PBXNativeTarget \"UTrello\" */; buildPhases = (A10000000000000000000093 /* Sources */, A10000000000000000000091 /* Frameworks */, A10000000000000000000081 /* Embed App Extensions */, ); buildRules = (); dependencies = (A100000000000000000000B2 /* PBXTargetDependency */, ); name = UTrello; productName = UTrello; productReference = A10000000000000000000040 /* UTrello.app */; productType = "com.apple.product-type.application"; };
|
||||||
|
A10000000000000000000071 /* UTrelloWidget */ = {isa = PBXNativeTarget; buildConfigurationList = A100000000000000000000B3 /* Build configuration list for PBXNativeTarget \"UTrelloWidget\" */; buildPhases = (A10000000000000000000094 /* Sources */, A10000000000000000000092 /* Frameworks */, ); buildRules = (); dependencies = (); name = UTrelloWidget; productName = UTrelloWidget; productReference = A10000000000000000000041 /* UTrelloWidget.appex */; productType = "com.apple.product-type.app-extension"; };
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
A10000000000000000000061 /* Project object */ = {isa = PBXProject; attributes = {BuildIndependentTargetsInParallel = 1; LastUpgradeCheck = 1500; TargetAttributes = {A10000000000000000000071 = {CreatedOnToolsVersion = 15.0; }; }; }; buildConfigurationList = A100000000000000000000B0 /* Build configuration list for PBXProject \"UTrello\" */; compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = (en, Base, ); mainGroup = A100000000000000000000A1; productRefGroup = A100000000000000000000A9 /* Products */; projectDirPath = ""; projectRoot = ""; targets = (A10000000000000000000070 /* UTrello */, A10000000000000000000071 /* UTrelloWidget */, ); };
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
A10000000000000000000093 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A10000000000000000000001 /* UTrelloApp.swift in Sources */, A10000000000000000000002 /* BoardView.swift in Sources */, A10000000000000000000003 /* TaskItem.swift in Sources */, A10000000000000000000004 /* TaskStore.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; };
|
||||||
|
A10000000000000000000094 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A10000000000000000000006 /* UTrelloWidget.swift in Sources */, A10000000000000000000007 /* UTrelloWidgetBundle.swift in Sources */, A10000000000000000000008 /* TaskItem.swift in Sources */, A10000000000000000000009 /* TaskStore.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; };
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
A100000000000000000000B2 /* PBXTargetDependency */ = {isa = PBXTargetDependency; target = A10000000000000000000071 /* UTrelloWidget */; targetProxy = A10000000000000000000051 /* PBXContainerItemProxy */; };
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
A100000000000000000000C1 /* Debug */ = {isa = XCBuildConfiguration; buildSettings = {CLANG_ENABLE_MODULES = YES; SWIFT_VERSION = 5.0; }; name = Debug; };
|
||||||
|
A100000000000000000000C2 /* Release */ = {isa = XCBuildConfiguration; buildSettings = {CLANG_ENABLE_MODULES = YES; SWIFT_VERSION = 5.0; }; name = Release; };
|
||||||
|
A100000000000000000000C3 /* Debug */ = {isa = XCBuildConfiguration; buildSettings = {CODE_SIGN_ENTITLEMENTS = UTrello/UTrello.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = UTrello; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; MACOSX_DEPLOYMENT_TARGET = 14.0; PRODUCT_BUNDLE_IDENTIFIER = com.example.UTrello; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; }; name = Debug; };
|
||||||
|
A100000000000000000000C4 /* Release */ = {isa = XCBuildConfiguration; buildSettings = {CODE_SIGN_ENTITLEMENTS = UTrello/UTrello.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = UTrello; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; MACOSX_DEPLOYMENT_TARGET = 14.0; PRODUCT_BUNDLE_IDENTIFIER = com.example.UTrello; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; }; name = Release; };
|
||||||
|
A100000000000000000000C5 /* Debug */ = {isa = XCBuildConfiguration; buildSettings = {APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = UTrelloWidget/UTrelloWidget.entitlements; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = UTrelloWidget/Info.plist; MACOSX_DEPLOYMENT_TARGET = 14.0; PRODUCT_BUNDLE_IDENTIFIER = com.example.UTrello.widget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_VERSION = 5.0; }; name = Debug; };
|
||||||
|
A100000000000000000000C6 /* Release */ = {isa = XCBuildConfiguration; buildSettings = {APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = UTrelloWidget/UTrelloWidget.entitlements; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = UTrelloWidget/Info.plist; MACOSX_DEPLOYMENT_TARGET = 14.0; PRODUCT_BUNDLE_IDENTIFIER = com.example.UTrello.widget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_VERSION = 5.0; }; name = Release; };
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
A100000000000000000000B0 /* Build configuration list for PBXProject \"UTrello\" */ = {isa = XCConfigurationList; buildConfigurations = (A100000000000000000000C1 /* Debug */, A100000000000000000000C2 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; };
|
||||||
|
A100000000000000000000B1 /* Build configuration list for PBXNativeTarget \"UTrello\" */ = {isa = XCConfigurationList; buildConfigurations = (A100000000000000000000C3 /* Debug */, A100000000000000000000C4 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; };
|
||||||
|
A100000000000000000000B3 /* Build configuration list for PBXNativeTarget \"UTrelloWidget\" */ = {isa = XCConfigurationList; buildConfigurations = (A100000000000000000000C5 /* Debug */, A100000000000000000000C6 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; };
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = A10000000000000000000061 /* Project object */;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme LastUpgradeVersion="1500" version="1.7">
|
||||||
|
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
|
||||||
|
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="A10000000000000000000070" BuildableName="UTrello.app" BlueprintName="UTrello" ReferencedContainer="container:UTrello.xcodeproj"/>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" allowLocationSimulation="YES">
|
||||||
|
<BuildableProductRunnable runnableDebuggingMode="0">
|
||||||
|
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="A10000000000000000000070" BuildableName="UTrello.app" BlueprintName="UTrello" ReferencedContainer="container:UTrello.xcodeproj"/>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction buildConfiguration="Release" shouldUseLaunchSchemeArgsEnv="YES" savedToolIdentifier="" useCustomWorkingDirectory="NO" debugDocumentVersioning="YES">
|
||||||
|
<BuildableProductRunnable runnableDebuggingMode="0">
|
||||||
|
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="A10000000000000000000070" BuildableName="UTrello.app" BlueprintName="UTrello" ReferencedContainer="container:UTrello.xcodeproj"/>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction buildConfiguration="Debug"/>
|
||||||
|
<ArchiveAction buildConfiguration="Release" revealArchiveInOrganizer="YES"/>
|
||||||
|
</Scheme>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct BoardView: View {
|
||||||
|
@EnvironmentObject private var store: TaskStore
|
||||||
|
@State private var newTaskTitle = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
Text("My board")
|
||||||
|
.font(.largeTitle.bold())
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
TextField("Add a task", text: $newTaskTitle)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.onSubmit(addTask)
|
||||||
|
Button("Add", action: addTask)
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(alignment: .top, spacing: 16) {
|
||||||
|
ForEach(TaskList.allCases) { list in
|
||||||
|
TaskColumn(list: list, tasks: tasks(in: list))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
.frame(minWidth: 780, minHeight: 460)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tasks(in list: TaskList) -> [TaskItem] {
|
||||||
|
store.tasks.filter { $0.list == list }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addTask() {
|
||||||
|
store.add(title: newTaskTitle)
|
||||||
|
newTaskTitle = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TaskColumn: View {
|
||||||
|
@EnvironmentObject private var store: TaskStore
|
||||||
|
let list: TaskList
|
||||||
|
let tasks: [TaskItem]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text(list.title)
|
||||||
|
.font(.headline)
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 8) {
|
||||||
|
ForEach(tasks) { task in
|
||||||
|
TaskCard(task: task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 320, alignment: .topLeading)
|
||||||
|
.background(.quaternary, in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TaskCard: View {
|
||||||
|
@EnvironmentObject private var store: TaskStore
|
||||||
|
let task: TaskItem
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 8) {
|
||||||
|
Text(task.title)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
Menu {
|
||||||
|
ForEach(TaskList.allCases.filter { $0 != task.list }) { list in
|
||||||
|
Button("Move to \(list.title)") { store.move(task, to: list) }
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
Button("Delete", role: .destructive) { store.delete(task) }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis")
|
||||||
|
}
|
||||||
|
.menuStyle(.borderlessButton)
|
||||||
|
.fixedSize()
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.background(.background, in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct UTrelloApp: App {
|
||||||
|
@StateObject private var store = TaskStore()
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
BoardView()
|
||||||
|
.environmentObject(store)
|
||||||
|
}
|
||||||
|
.windowResizability(.contentSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum TaskList: String, CaseIterable, Codable, Identifiable {
|
||||||
|
case todo
|
||||||
|
case doing
|
||||||
|
case done
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .todo: "To do"
|
||||||
|
case .doing: "Doing"
|
||||||
|
case .done: "Done"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TaskItem: Identifiable, Codable, Equatable {
|
||||||
|
let id: UUID
|
||||||
|
var title: String
|
||||||
|
var list: TaskList
|
||||||
|
let createdAt: Date
|
||||||
|
|
||||||
|
init(id: UUID = UUID(), title: String, list: TaskList = .todo, createdAt: Date = .now) {
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
self.list = list
|
||||||
|
self.createdAt = createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
#if canImport(WidgetKit)
|
||||||
|
import WidgetKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
final class TaskStore: ObservableObject {
|
||||||
|
static let groupIdentifier = "group.com.example.utrello"
|
||||||
|
private static let tasksKey = "tasks"
|
||||||
|
|
||||||
|
@Published private(set) var tasks: [TaskItem] {
|
||||||
|
didSet { save() }
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
tasks = Self.loadTasks()
|
||||||
|
}
|
||||||
|
|
||||||
|
func add(title: String, to list: TaskList = .todo) {
|
||||||
|
let title = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !title.isEmpty else { return }
|
||||||
|
tasks.insert(TaskItem(title: title, list: list), at: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func move(_ task: TaskItem, to list: TaskList) {
|
||||||
|
guard let index = tasks.firstIndex(of: task) else { return }
|
||||||
|
tasks[index].list = list
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete(_ task: TaskItem) {
|
||||||
|
tasks.removeAll { $0.id == task.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
guard let data = try? JSONEncoder().encode(tasks) else { return }
|
||||||
|
Self.defaults.set(data, forKey: Self.tasksKey)
|
||||||
|
#if canImport(WidgetKit)
|
||||||
|
WidgetCenter.shared.reloadTimelines(ofKind: "UTrelloTasksWidget")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadTasks() -> [TaskItem] {
|
||||||
|
guard
|
||||||
|
let data = defaults.data(forKey: tasksKey),
|
||||||
|
let tasks = try? JSONDecoder().decode([TaskItem].self, from: data)
|
||||||
|
else { return [] }
|
||||||
|
return tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
private static var defaults: UserDefaults {
|
||||||
|
UserDefaults(suiteName: groupIdentifier) ?? .standard
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?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>com.apple.security.app-sandbox</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.com.example.utrello</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?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>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>UTrello Widget</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>XPC!</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.widgetkit-extension</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?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>com.apple.security.app-sandbox</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.com.example.utrello</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TaskEntry: TimelineEntry {
|
||||||
|
let date: Date
|
||||||
|
let tasks: [TaskItem]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TaskProvider: TimelineProvider {
|
||||||
|
func placeholder(in context: Context) -> TaskEntry {
|
||||||
|
TaskEntry(date: .now, tasks: [TaskItem(title: "Plan the next task")])
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSnapshot(in context: Context, completion: @escaping (TaskEntry) -> Void) {
|
||||||
|
completion(TaskEntry(date: .now, tasks: openTasks()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTimeline(in context: Context, completion: @escaping (Timeline<TaskEntry>) -> Void) {
|
||||||
|
let entry = TaskEntry(date: .now, tasks: openTasks())
|
||||||
|
let refresh = Calendar.current.date(byAdding: .minute, value: 15, to: .now)!
|
||||||
|
completion(Timeline(entries: [entry], policy: .after(refresh)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openTasks() -> [TaskItem] {
|
||||||
|
TaskStore.loadTasks().filter { $0.list != .done }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UTrelloTasksWidget: Widget {
|
||||||
|
let kind = "UTrelloTasksWidget"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
StaticConfiguration(kind: kind, provider: TaskProvider()) { entry in
|
||||||
|
TaskWidgetView(entry: entry)
|
||||||
|
}
|
||||||
|
.configurationDisplayName("UTrello Tasks")
|
||||||
|
.description("Your unfinished tasks at a glance.")
|
||||||
|
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TaskWidgetView: View {
|
||||||
|
let entry: TaskEntry
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("My tasks")
|
||||||
|
.font(.headline)
|
||||||
|
if entry.tasks.isEmpty {
|
||||||
|
Spacer()
|
||||||
|
Text("All caught up")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
} else {
|
||||||
|
ForEach(entry.tasks.prefix(4)) { task in
|
||||||
|
Label(task.title, systemImage: task.list == .doing ? "play.circle" : "circle")
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Text("\(entry.tasks.count) open")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.containerBackground(for: .widget) { Color.clear }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct UTrelloWidgetBundle: WidgetBundle {
|
||||||
|
var body: some Widget {
|
||||||
|
UTrelloTasksWidget()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user