├── .swiftlint.yml
├── clmn.png
├── clmn1.png
├── clmn2.png
├── clmn3.png
├── mvvm.png
├── Clmn
├── Fonts
│ ├── PeriodicoDisplay-Bd.ttf
│ └── PeriodicoDisplay-Rg.ttf
├── Assets.xcassets
│ ├── Contents.json
│ ├── Colors
│ │ ├── Contents.json
│ │ ├── SideBarBackgroundColor.colorset
│ │ │ └── Contents.json
│ │ ├── AccentColor.colorset
│ │ │ └── Contents.json
│ │ ├── ListSelect.colorset
│ │ │ └── Contents.json
│ │ ├── TaskColor0.colorset
│ │ │ └── Contents.json
│ │ ├── TaskColor1.colorset
│ │ │ └── Contents.json
│ │ ├── TaskColor2.colorset
│ │ │ └── Contents.json
│ │ ├── TaskColor3.colorset
│ │ │ └── Contents.json
│ │ ├── TaskColor4.colorset
│ │ │ └── Contents.json
│ │ ├── TaskColor5.colorset
│ │ │ └── Contents.json
│ │ ├── TaskNote.colorset
│ │ │ └── Contents.json
│ │ ├── listOffText.colorset
│ │ │ └── Contents.json
│ │ └── ListBackground.colorset
│ │ │ └── Contents.json
│ ├── Images
│ │ ├── Contents.json
│ │ └── AppBW.imageset
│ │ │ ├── clmn-bw.png
│ │ │ └── Contents.json
│ ├── URLIcon.iconset
│ │ ├── icon_16x16.png
│ │ ├── icon_32x32.png
│ │ ├── icon_128x128.png
│ │ ├── icon_16x16@2x.png
│ │ ├── icon_256x256.png
│ │ ├── icon_32x32@2x.png
│ │ ├── icon_512x512.png
│ │ ├── icon_128x128@2x.png
│ │ ├── icon_256x256@2x.png
│ │ └── icon_512x512@2x.png
│ └── AppIcon.appiconset
│ │ ├── icon_16x16.png
│ │ ├── icon_32x32.png
│ │ ├── icon_128x128.png
│ │ ├── icon_16x16@2x.png
│ │ ├── icon_256x256.png
│ │ ├── icon_32x32@2x.png
│ │ ├── icon_512x512.png
│ │ ├── icon_128x128@2x.png
│ │ ├── icon_256x256@2x.png
│ │ ├── icon_512x512@2x.png
│ │ └── Contents.json
├── Preview Content
│ └── Preview Assets.xcassets
│ │ └── Contents.json
├── Models
│ ├── AppMetaData.swift
│ ├── Board.swift
│ ├── ModelOpt.swift
│ ├── ModelPairOpt.swift
│ ├── Task.swift
│ ├── TaskGroup.swift
│ └── TaskList.swift
├── UI
│ ├── GoldenRatio.swift
│ ├── Appearance.swift
│ ├── Colors.swift
│ └── Typography.swift
├── Library
│ ├── +NSTextField.swift
│ ├── +NSTextView.swift
│ ├── Hide.swift
│ ├── +View+Placeholder.swift
│ ├── +Binding.swift
│ ├── +NSColor.swift
│ ├── +View.swift
│ ├── +Bundle.swift
│ ├── +Array.swift
│ ├── +String.swift
│ ├── +View+RoundedCorners.swift
│ └── +Color.swift
├── Util
│ ├── LazyView.swift
│ ├── CursorUtil.swift
│ ├── DeleteIntent.swift
│ ├── Queue.swift
│ ├── EmptyNavigationLink.swift
│ ├── Icons.swift
│ ├── ForEachWithIndex.swift
│ └── SideBarUtil.swift
├── Credits.html
├── Services
│ ├── Services.swift
│ ├── BoardsService.swift
│ ├── AppService.swift
│ └── TaskListsService.swift
├── Clmn.entitlements
├── AppUpgrade.swift
├── Views
│ ├── DragDrop
│ │ ├── DragBoardModel.swift
│ │ ├── DragTaskListModel.swift
│ │ ├── DragTaskGroupModel.swift
│ │ ├── OnDragBoard+DropOnBoard.swift
│ │ ├── OnDragTask+DropOnTaskList.swift
│ │ ├── OnDragTaskList+DropOnTaskList.swift
│ │ ├── DragTaskModel.swift
│ │ ├── OnDragTask+DropOnTaskGroup.swift
│ │ ├── OnDragTaskGroup+DropOnTaskGroup.swift
│ │ ├── OnDragTask+DropOnTask.swift
│ │ ├── Many+DropOnTaskList.swift
│ │ └── Many+DropOnTaskGroup.swift
│ ├── Task
│ │ ├── TaskColorRadioButtons.swift
│ │ ├── DeleteTaskConfirmationDialog.swift
│ │ ├── TaskSheet.swift
│ │ ├── AddTaskButtons.swift
│ │ └── TaskView.swift
│ ├── Settings
│ │ ├── SettingsView.swift
│ │ ├── BehaviourSettingsView.swift
│ │ └── BoardSettingsView.swift
│ ├── Main
│ │ ├── DeleteBoardConfirmationDialog.swift
│ │ ├── ZeroBoardsView.swift
│ │ ├── SplitView.swift
│ │ └── MainView.swift
│ ├── TaskList
│ │ ├── DeleteTaskListConfirmationDialog.swift
│ │ ├── TaskListButton.swift
│ │ ├── TaskListSheet.swift
│ │ ├── TaskListTitle.swift
│ │ └── TaskListView.swift
│ ├── TaskGroup
│ │ ├── DeleteTaskGroupConfirmationDialog.swift
│ │ ├── TaskGroupSheet.swift
│ │ └── TaskGroupView.swift
│ ├── Board
│ │ ├── EmptyBoardView.swift
│ │ ├── BoardSheet.swift
│ │ └── BoardView.swift
│ └── About
│ │ └── AppAbout.swift
├── Components
│ ├── Form
│ │ ├── FormLabel.swift
│ │ ├── FormDescription.swift
│ │ ├── FormTextField.swift
│ │ └── FormTextEditor.swift
│ ├── ColorCheckBox.swift
│ ├── SheetHeader.swift
│ ├── SheetCancelOk.swift
│ └── MacTextEditor.swift
├── Info.plist
├── ViewModels
│ ├── AllBoardsViewModel.swift
│ ├── AllTaskListsViewModel.swift
│ └── TaskListViewModel.swift
├── AppExample.swift
└── ClmnApp.swift
├── Clmn.xcodeproj
├── project.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ ├── IDEWorkspaceChecks.plist
│ │ └── swiftpm
│ │ └── Package.resolved
└── xcuserdata
│ └── ispasic.xcuserdatad
│ └── xcschemes
│ └── xcschememanagement.plist
├── .editorconfig
├── .gitignore
├── README.md
├── LICENSE
└── DEV.md
/.swiftlint.yml:
--------------------------------------------------------------------------------
1 | line_length: 120
--------------------------------------------------------------------------------
/clmn.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/igr/Clmn/HEAD/clmn.png
--------------------------------------------------------------------------------
/clmn1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/igr/Clmn/HEAD/clmn1.png
--------------------------------------------------------------------------------
/clmn2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/igr/Clmn/HEAD/clmn2.png
--------------------------------------------------------------------------------
/clmn3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/igr/Clmn/HEAD/clmn3.png
--------------------------------------------------------------------------------
/mvvm.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/igr/Clmn/HEAD/mvvm.png
--------------------------------------------------------------------------------
/Clmn/Fonts/PeriodicoDisplay-Bd.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/igr/Clmn/HEAD/Clmn/Fonts/PeriodicoDisplay-Bd.ttf
--------------------------------------------------------------------------------
/Clmn/Fonts/PeriodicoDisplay-Rg.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/igr/Clmn/HEAD/Clmn/Fonts/PeriodicoDisplay-Rg.ttf
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/Colors/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/Images/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/URLIcon.iconset/icon_16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/igr/Clmn/HEAD/Clmn/Assets.xcassets/URLIcon.iconset/icon_16x16.png
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/URLIcon.iconset/icon_32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/igr/Clmn/HEAD/Clmn/Assets.xcassets/URLIcon.iconset/icon_32x32.png
--------------------------------------------------------------------------------
/Clmn/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/AppIcon.appiconset/icon_16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/igr/Clmn/HEAD/Clmn/Assets.xcassets/AppIcon.appiconset/icon_16x16.png
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/AppIcon.appiconset/icon_32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/igr/Clmn/HEAD/Clmn/Assets.xcassets/AppIcon.appiconset/icon_32x32.png
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/Images/AppBW.imageset/clmn-bw.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/igr/Clmn/HEAD/Clmn/Assets.xcassets/Images/AppBW.imageset/clmn-bw.png
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/URLIcon.iconset/icon_128x128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/igr/Clmn/HEAD/Clmn/Assets.xcassets/URLIcon.iconset/icon_128x128.png
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/URLIcon.iconset/icon_16x16@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/igr/Clmn/HEAD/Clmn/Assets.xcassets/URLIcon.iconset/icon_16x16@2x.png
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/URLIcon.iconset/icon_256x256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/igr/Clmn/HEAD/Clmn/Assets.xcassets/URLIcon.iconset/icon_256x256.png
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/URLIcon.iconset/icon_32x32@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/igr/Clmn/HEAD/Clmn/Assets.xcassets/URLIcon.iconset/icon_32x32@2x.png
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/URLIcon.iconset/icon_512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/igr/Clmn/HEAD/Clmn/Assets.xcassets/URLIcon.iconset/icon_512x512.png
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/AppIcon.appiconset/icon_128x128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/igr/Clmn/HEAD/Clmn/Assets.xcassets/AppIcon.appiconset/icon_128x128.png
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/igr/Clmn/HEAD/Clmn/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/AppIcon.appiconset/icon_256x256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/igr/Clmn/HEAD/Clmn/Assets.xcassets/AppIcon.appiconset/icon_256x256.png
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/igr/Clmn/HEAD/Clmn/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/AppIcon.appiconset/icon_512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/igr/Clmn/HEAD/Clmn/Assets.xcassets/AppIcon.appiconset/icon_512x512.png
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/URLIcon.iconset/icon_128x128@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/igr/Clmn/HEAD/Clmn/Assets.xcassets/URLIcon.iconset/icon_128x128@2x.png
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/URLIcon.iconset/icon_256x256@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/igr/Clmn/HEAD/Clmn/Assets.xcassets/URLIcon.iconset/icon_256x256@2x.png
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/URLIcon.iconset/icon_512x512@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/igr/Clmn/HEAD/Clmn/Assets.xcassets/URLIcon.iconset/icon_512x512@2x.png
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/igr/Clmn/HEAD/Clmn/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/igr/Clmn/HEAD/Clmn/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/igr/Clmn/HEAD/Clmn/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png
--------------------------------------------------------------------------------
/Clmn/Models/AppMetaData.swift:
--------------------------------------------------------------------------------
1 |
2 | struct AppMetaData: Equatable, Hashable, Codable {
3 |
4 | var appVersion: Int
5 | var dataVersion: Int
6 |
7 | }
8 |
--------------------------------------------------------------------------------
/Clmn.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Clmn/UI/GoldenRatio.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | let goldenRatio = GoldenRatio()
4 |
5 | struct GoldenRatio {
6 | func of(_ value: Int) -> CGFloat {
7 | CGFloat(Double(value) * 1.61803398875)
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | indent_size = 4
6 | end_of_line = lf
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 |
11 | [*.{snap,swift}]
12 | indent_size = 4
13 |
--------------------------------------------------------------------------------
/Clmn/Library/+NSTextField.swift:
--------------------------------------------------------------------------------
1 | import AppKit
2 |
3 | extension NSTextField {
4 | open override var focusRingType: NSFocusRingType {
5 | get {
6 | .none
7 | }
8 | set {
9 | }
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea
2 | .DS_Store
3 |
4 | ## User settings
5 | xcuserdata/
6 |
7 | ## Obj-C/Swift specific
8 | *.hmap
9 |
10 | ## App packaging
11 | *.ipa
12 | *.dSYM.zip
13 | *.dSYM
14 |
15 | ## Playgrounds
16 | timeline.xctimeline
17 | playground.xcworkspace
18 |
19 |
20 | .build/
21 |
22 |
--------------------------------------------------------------------------------
/Clmn/Util/LazyView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | public struct LazyView: View {
4 | private let build: () -> Content
5 | public init(_ build: @autoclosure @escaping () -> Content) {
6 | self.build = build
7 | }
8 | public var body: Content {
9 | build()
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Clmn/Library/+NSTextView.swift:
--------------------------------------------------------------------------------
1 | import AppKit
2 |
3 | extension NSTextView {
4 | // Removes the background, so we can present the placeholder.
5 | open override var frame: CGRect {
6 | didSet {
7 | backgroundColor = .clear
8 | drawsBackground = true
9 | }
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Clmn/Library/Hide.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct Show: ViewModifier {
4 | let isVisible: Bool
5 |
6 | @ViewBuilder
7 | func body(content: Content) -> some View {
8 | if isVisible {
9 | content
10 | } else {
11 | content.hidden()
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/Clmn.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Clmn/Credits.html:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/Clmn/Services/Services.swift:
--------------------------------------------------------------------------------
1 | public struct Services {
2 | let app: AppService
3 | let boards: BoardsService
4 | let lists: TaskListsService
5 | }
6 |
7 | /// Static reference to all services.
8 | let services = Services(
9 | app: AppService(),
10 | boards: BoardsService(),
11 | lists: TaskListsService()
12 | )
13 |
--------------------------------------------------------------------------------
/Clmn/Clmn.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.app-sandbox
6 |
7 | com.apple.security.files.user-selected.read-only
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/Clmn/Util/CursorUtil.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | class CursorUtil {
4 |
5 | public static func changeCursorOnHover(_ isHovered: Bool, cursor: NSCursor) {
6 | DispatchQueue.main.async {
7 | if isHovered {
8 | cursor.push()
9 | } else {
10 | NSCursor.pop()
11 | }
12 | }
13 | }
14 |
15 | }
16 |
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/Images/AppBW.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "scale" : "1x"
6 | },
7 | {
8 | "filename" : "clmn-bw.png",
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Clmn/AppUpgrade.swift:
--------------------------------------------------------------------------------
1 | /// Upgrades data structure.
2 | func upgradeData(from: Int, to: Int) {
3 | if (from == to) {
4 | return
5 | }
6 | var current = from
7 | while (current < to) {
8 | switch current {
9 | case 0: upgradeV0_V1()
10 | default: print("Upgrade done.")
11 | }
12 | current += 1
13 | }
14 |
15 | }
16 |
17 | private func upgradeV0_V1() {
18 | // nothing to do
19 | }
20 |
--------------------------------------------------------------------------------
/Clmn/Library/+View+Placeholder.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | extension View {
4 | /// Adds a placeholder to any view, sort of.
5 | func placeholder(
6 | when shouldShow: Bool,
7 | alignment: Alignment = .leading,
8 | @ViewBuilder placeholder: () -> Content) -> some View {
9 |
10 | ZStack(alignment: alignment) {
11 | placeholder().opacity(shouldShow ? 1 : 0)
12 | self
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/Colors/SideBarBackgroundColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0x21",
9 | "green" : "0x21",
10 | "red" : "0x21"
11 | }
12 | },
13 | "idiom" : "universal"
14 | }
15 | ],
16 | "info" : {
17 | "author" : "xcode",
18 | "version" : 1
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Clmn
2 |
3 | Beautiful macOS app that operates with tasks in columns.
4 |
5 | 
6 |
7 | Features:
8 |
9 | + Any number of columns
10 | + Group tasks
11 | + Mark tasks as in progress (2 states)
12 | + Drag-n-drop enabled
13 | + Markdown supported
14 |
15 | ## Screenshots
16 |
17 | (not updated frequently.)
18 |
19 | ### Multiple clients
20 | 
21 |
22 | ### Kanban board
23 | 
24 |
25 | ### Just a bunch of tasks
26 | 
27 |
--------------------------------------------------------------------------------
/Clmn/Util/DeleteIntent.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// Simple struct to store pair of values:
4 | /// one flag for the UI confirmation dialog and the target item.
5 | struct DeleteIntent {
6 | var isPresented: Bool = false
7 | var target: T? = nil
8 |
9 | mutating func set(_ target: T) {
10 | self.target = target
11 | isPresented = true
12 | }
13 |
14 | mutating func reset() {
15 | isPresented = false
16 | target = nil
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/Clmn/Views/DragDrop/DragBoardModel.swift:
--------------------------------------------------------------------------------
1 | import UniformTypeIdentifiers
2 |
3 | let BOARD_UTI = UTType(APP_GROUP + ".board")!
4 |
5 | class DragBoardModel: ObservableObject {
6 | @Published var board: Board?
7 |
8 | func startDragOf(_ board: Board) -> NSItemProvider {
9 | self.board = board
10 | return NSItemProvider(item: board.name as NSString, typeIdentifier: BOARD_UTI.identifier)
11 | }
12 |
13 | func stopDrop() {
14 | board = nil
15 | }
16 |
17 | }
18 |
--------------------------------------------------------------------------------
/Clmn/Views/DragDrop/DragTaskListModel.swift:
--------------------------------------------------------------------------------
1 | import UniformTypeIdentifiers
2 |
3 | let TASKLIST_UTI = UTType(APP_GROUP + ".list")!
4 |
5 | class DragTaskListModel: ObservableObject {
6 | @Published var list: TaskList?
7 |
8 | func startDragOf(_ list: TaskList) -> NSItemProvider {
9 | self.list = list
10 | return NSItemProvider(item: list.title as NSString, typeIdentifier: TASKLIST_UTI.identifier)
11 | }
12 |
13 | func stopDrop() {
14 | list = nil
15 | }
16 |
17 | }
18 |
--------------------------------------------------------------------------------
/Clmn/Components/Form/FormLabel.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct FormLabel: View {
4 | private var text: String
5 |
6 | init(_ text: String) {
7 | self.text = text
8 | }
9 |
10 | var body: some View {
11 | Text(text)
12 | .padding(.top, 2)
13 | .frame(height: 18)
14 | .font(Font.App.formField)
15 | }
16 | }
17 |
18 | struct FormLabel_Previews: PreviewProvider {
19 | static var previews: some View {
20 | FormLabel("Hello")
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/Clmn/Library/+Binding.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import AppKit
3 |
4 | /*
5 | Because all core data fields are optional, you have to wrap it into Binding.
6 | And as you're using core data and may face a lot of optionals, you can create an extension.
7 | Usage: $newTask.name.unwrap(defaultValue: "")
8 | */
9 | extension Binding {
10 | func unwrap(defaultValue: T) -> Binding where Value == Optional {
11 | Binding(get: { self.wrappedValue ?? defaultValue }, set: { self.wrappedValue = $0 })
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/Clmn/Views/DragDrop/DragTaskGroupModel.swift:
--------------------------------------------------------------------------------
1 | import UniformTypeIdentifiers
2 |
3 | let TASKGROUP_UTI = UTType(APP_GROUP + ".group")!
4 |
5 | class DragTaskGroupModel: ObservableObject {
6 | @Published var group: (TaskList, TaskGroup)?
7 |
8 | func startDragOf(_ owner: TaskList, _ group: TaskGroup) -> NSItemProvider {
9 | self.group = (owner, group)
10 | return NSItemProvider(item: group.name as NSString, typeIdentifier: TASKGROUP_UTI.identifier)
11 | }
12 |
13 | func stopDrop() {
14 | group = nil
15 | }
16 |
17 | }
18 |
--------------------------------------------------------------------------------
/Clmn/Library/+NSColor.swift:
--------------------------------------------------------------------------------
1 | import AppKit
2 |
3 | extension NSColor {
4 |
5 | /// Returns color instance from RGB values (0-255)
6 | static func fromRGB(red: Double, green: Double, blue: Double, alpha: Double = 100.0) -> NSColor {
7 |
8 | let rgbRed = CGFloat(red/255)
9 | let rgbGreen = CGFloat(green/255)
10 | let rgbBlue = CGFloat(blue/255)
11 | let rgbAlpha = CGFloat(alpha/100)
12 |
13 | let color = NSColor(red: rgbRed, green: rgbGreen, blue: rgbBlue, alpha: rgbAlpha)
14 | return color
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/Clmn/Models/Board.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | typealias BoardId = UUID
4 |
5 | struct Board: Identifiable, Equatable, Hashable, Codable {
6 |
7 | var id: BoardId = BoardId()
8 | var name: String
9 | // meta
10 | var timestamp: Date = Date.now
11 |
12 | public static let foo = Board(name: "n/a")
13 | }
14 |
15 | extension Array where Element == Board {
16 |
17 | func with(_ board: Board, consumer: (Int) -> Void) {
18 | guard let index = firstIndex(where: { b in b.id == board.id } ) else { return }
19 | consumer(index)
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Clmn/Components/Form/FormDescription.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct FormDescription: View {
4 | private var text: String
5 |
6 | init(_ text: String) {
7 | self.text = text
8 | }
9 |
10 | var body: some View {
11 | Text(text)
12 | .fixedSize(horizontal: false, vertical: true)
13 | .padding(.top, 2)
14 | .font(Font.App.formDescription)
15 | }
16 | }
17 |
18 | struct FormDescription_Previews: PreviewProvider {
19 | static var previews: some View {
20 | FormDescription("This is a description")
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/Clmn/Models/ModelOpt.swift:
--------------------------------------------------------------------------------
1 | import CoreData
2 |
3 | /// Optional container used for adding/editing model details.
4 | struct ModelOpt: Identifiable {
5 | let id: UUID = UUID()
6 | let model: T?
7 |
8 | init(model: T?) {
9 | self.model = model
10 | }
11 |
12 | static func ofNew() -> ModelOpt {
13 | ModelOpt(model: nil)
14 | }
15 |
16 | static func of(_ model: R) -> ModelOpt {
17 | ModelOpt(model: model)
18 | }
19 |
20 | /// Returns true if optional model exists.
21 | func exists() -> Bool {
22 | model != nil
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Clmn/Util/Queue.swift:
--------------------------------------------------------------------------------
1 | struct Queue {
2 | private var elements: [T] = []
3 |
4 | mutating func push(_ value: T) {
5 | elements.append(value)
6 | }
7 |
8 | mutating func pop() -> T? {
9 | guard !elements.isEmpty else {
10 | return nil
11 | }
12 | return elements.removeFirst()
13 | }
14 |
15 | var head: T? {
16 | elements.first
17 | }
18 |
19 | mutating func clear() {
20 | elements.removeAll()
21 | }
22 |
23 | var tail: T? {
24 | elements.last
25 | }
26 |
27 | var size: Int { elements.endIndex }
28 | }
29 |
--------------------------------------------------------------------------------
/Clmn/Views/DragDrop/OnDragBoard+DropOnBoard.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct DragBoardDropOnBoard: DropDelegate {
4 | var source: DragBoardModel
5 | var target: Board
6 | var reorder: (_: Board, _: Board) -> Void
7 |
8 | func performDrop(info: DropInfo) -> Bool {
9 | guard info.hasItemsConforming(to: [BOARD_UTI]) else { return false }
10 | guard let origin = source.board else { return false }
11 | source.stopDrop()
12 |
13 | if (origin.id == target.id) {
14 | return false
15 | }
16 |
17 | reorder(origin, target)
18 | return true
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Clmn/Library/+View.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | extension View {
4 |
5 | /// Solution to control attributes inclusion/exclusion.
6 | /// Usage: `.if(colored) { view in view.background(Color.blue) }`
7 | @ViewBuilder
8 | func `if`(_ condition: Bool, transform: (Self) -> Transform) -> some View {
9 | if condition { transform(self) }
10 | else { self }
11 | }
12 |
13 | }
14 |
15 | func withAnimation(if condition: Bool, block: () -> Void) {
16 | if condition {
17 | withAnimation {
18 | block()
19 | }
20 | } else {
21 | block()
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/Clmn/Components/ColorCheckBox.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct ColorCheckBox: View {
4 |
5 | @Binding var selectedColor: Int
6 | var color: (Int, Color)
7 |
8 | private func selected() -> Bool {
9 | self.selectedColor == color.0
10 | }
11 |
12 | var body: some View {
13 | Button(action: { self.selectedColor = self.color.0 }) {
14 | Image(systemName: selected() ? "checkmark.square.fill" : "square.fill")
15 | .resizable()
16 | .frame(width: 20, height: 20)
17 | }
18 | .buttonStyle(.borderless)
19 | .tint(self.color.1)
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Clmn/Views/Task/TaskColorRadioButtons.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct TaskColorRadioButtons: View {
4 | @Binding var selectedColor: Int
5 | @Environment(\.colorScheme) var colorScheme
6 |
7 | var body: some View {
8 | HStack {
9 | FormLabel("Background:")
10 | ForEachWithIndex(Color.App.taskColors, id: \.description) { index, color in
11 | ColorCheckBox(selectedColor: $selectedColor, color: (index, color))
12 | .colorScheme(colorScheme == .dark ? .light : .dark)
13 | }
14 | Spacer()
15 | }
16 | .padding([.top,.bottom])
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/Clmn/Views/DragDrop/OnDragTask+DropOnTaskList.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct DragTaskDropOnTaskList: DropDelegate {
4 | var source: DragTaskModel
5 | var target: TaskList
6 |
7 | var append: (_: Task) -> Void
8 |
9 | func performDrop(info: DropInfo) -> Bool {
10 | guard info.hasItemsConforming(to: [TASK_UTI]) else { return false }
11 | guard let origin = source.task else { return false }
12 | let originRemoveOnDrop = source.removeOnDrop
13 | source.stopDrop()
14 |
15 | if (origin.0.id == target.id) {
16 | return false
17 | }
18 |
19 | originRemoveOnDrop(origin.2)
20 | append(origin.2)
21 |
22 | return true
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Clmn/Views/DragDrop/OnDragTaskList+DropOnTaskList.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// TaskLists are only dragged within the same board.
4 | /// They are simply reordered.
5 | struct DragTaskListDropOnTaskList: DropDelegate {
6 | var source: DragTaskListModel
7 | var target: TaskList
8 | var reorder: (_: TaskList, _: TaskList) -> Void
9 |
10 | func performDrop(info: DropInfo) -> Bool {
11 | guard info.hasItemsConforming(to: [TASKLIST_UTI]) else { return false }
12 | guard let origin = source.list else { return false }
13 | source.stopDrop()
14 |
15 | if (origin.id == target.id) {
16 | return false
17 | }
18 |
19 | reorder(origin, target)
20 | return true
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/Clmn/UI/Appearance.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// Appearance fiddling.
4 | enum Appearance: String, CaseIterable, Identifiable {
5 | case system
6 | case light
7 | case dark
8 |
9 | var id: String {
10 | self.rawValue
11 | }
12 |
13 | /// The working way of theme change across the application.
14 | static func applyTheme(_ theme: Appearance) {
15 | @AppStorage("appThemeSetting") var appThemeSetting = Appearance.system
16 |
17 | appThemeSetting = theme
18 |
19 | switch theme {
20 | case .light:
21 | NSApp.appearance = NSAppearance(named: .vibrantLight)
22 | case .dark:
23 | NSApp.appearance = NSAppearance(named: .darkAqua)
24 | default:
25 | NSApp.appearance = nil
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/Clmn/Models/ModelPairOpt.swift:
--------------------------------------------------------------------------------
1 | import CoreData
2 |
3 | /// Optional model holder.
4 | struct ModelPairOpt: Identifiable {
5 | let id: UUID = UUID()
6 | let model: T?
7 | let owner: O
8 |
9 | init(owner: O, model: T?) {
10 | self.model = model
11 | self.owner = owner
12 | }
13 |
14 | static func ofNew(_ owner: A) -> ModelPairOpt {
15 | ModelPairOpt(owner: owner, model: nil)
16 | }
17 |
18 | static func of(_ owner: A, _ model: B) -> ModelPairOpt {
19 | ModelPairOpt(owner: owner, model: model)
20 | }
21 |
22 | /// Returns true if optional model exists.
23 | func exists() -> Bool {
24 | model != nil
25 | }
26 |
27 | func just() -> ModelOpt {
28 | ModelOpt(model: model)
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/Clmn/Views/Settings/SettingsView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | let SETTINGS_TASK_CHECKBOX_IMAGE = "taskCheckboxImage"
4 | let SETTINGS_TASK_SELECTABLE = "taskSelectable"
5 | let SETTINGS_HOVER_EDIT = "hoverEdit"
6 |
7 | struct SettingsView: View {
8 | var body: some View {
9 | TabView {
10 | BoardSettingsView()
11 | .tabItem {
12 | Label("General", systemImage: Icons.settingsGeneral)
13 | }
14 | BehaviourSettingsView()
15 | .tabItem {
16 | Label("Behaviour", systemImage: Icons.settingsBehaviour)
17 | }
18 | }
19 | .frame(width: 450, height: 250)
20 | }
21 | }
22 |
23 | struct SettingsView_Previews: PreviewProvider {
24 | static var previews: some View {
25 | SettingsView()
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/Clmn/Views/Settings/BehaviourSettingsView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct BehaviourSettingsView: View {
4 |
5 | @AppStorage(SETTINGS_HOVER_EDIT) private var hoverEdit = true
6 |
7 | var body: some View {
8 | VStack(alignment: .leading, spacing: .zero) {
9 | Toggle("Use hover editing tools", isOn: $hoverEdit)
10 | .font(Font.App.formField)
11 | .padding([.top], 20)
12 | FormDescription("When enabled, hovering over the board elements shows editing tools. When disabled, you may still use the context-menu instead.")
13 | .padding([.leading], 20)
14 |
15 | Spacer()
16 | }
17 | .padding()
18 | }
19 | }
20 |
21 | struct BehaviourSettingsView_Previews: PreviewProvider {
22 | static var previews: some View {
23 | BehaviourSettingsView()
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/Clmn/Views/DragDrop/DragTaskModel.swift:
--------------------------------------------------------------------------------
1 | import UniformTypeIdentifiers
2 |
3 | let TASK_UTI = UTType(APP_GROUP + ".task")!
4 |
5 | class DragTaskModel: ObservableObject {
6 | @Published var task: (TaskList, TaskGroup, Task)?
7 |
8 | // When task is moved between the owners (i.e. from one list to the another),
9 | // we need this action to remove the task from the previous container.
10 | var removeOnDrop: (_: Task) -> Void = { task in }
11 |
12 | func startDragOf(_ task: (TaskList, TaskGroup, Task), removeOnDrop: @escaping (_: Task) -> Void = { task in }) -> NSItemProvider {
13 | self.task = task
14 | self.removeOnDrop = removeOnDrop
15 | return NSItemProvider(item: task.1.name as NSString, typeIdentifier: TASK_UTI.identifier)
16 | }
17 |
18 | func stopDrop() {
19 | task = nil
20 | removeOnDrop = { task in }
21 | }
22 |
23 | }
24 |
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/Colors/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0x33",
9 | "green" : "0x33",
10 | "red" : "0x33"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0xBB",
27 | "green" : "0xBB",
28 | "red" : "0xBB"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/Colors/ListSelect.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0x33",
9 | "green" : "0x33",
10 | "red" : "0x33"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0xEE",
27 | "green" : "0xEE",
28 | "red" : "0xEE"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/Colors/TaskColor0.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0xDD",
9 | "green" : "0xDD",
10 | "red" : "0xDD"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0x33",
27 | "green" : "0x33",
28 | "red" : "0x33"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/Colors/TaskColor1.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0xE7",
9 | "green" : "0xBE",
10 | "red" : "0xE1"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0x9A",
27 | "green" : "0x1B",
28 | "red" : "0x6A"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/Colors/TaskColor2.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0xD2",
9 | "green" : "0xCD",
10 | "red" : "0xFF"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0x28",
27 | "green" : "0x28",
28 | "red" : "0xC6"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/Colors/TaskColor3.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0xC4",
9 | "green" : "0xF9",
10 | "red" : "0xFF"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0x25",
27 | "green" : "0xA8",
28 | "red" : "0xF9"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/Colors/TaskColor4.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0xC9",
9 | "green" : "0xE6",
10 | "red" : "0xC8"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0x32",
27 | "green" : "0x7D",
28 | "red" : "0x2E"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/Colors/TaskColor5.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0xFB",
9 | "green" : "0xDE",
10 | "red" : "0xBB"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0xC0",
27 | "green" : "0x65",
28 | "red" : "0x15"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/Colors/TaskNote.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0x33",
9 | "green" : "0x33",
10 | "red" : "0x33"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0xDD",
27 | "green" : "0xDD",
28 | "red" : "0xDD"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/Colors/listOffText.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0xCC",
9 | "green" : "0xCC",
10 | "red" : "0xCC"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0x66",
27 | "green" : "0x66",
28 | "red" : "0x66"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Clmn/Views/DragDrop/OnDragTask+DropOnTaskGroup.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct DragTaskDropOnTaskGroup: DropDelegate {
4 | var source: DragTaskModel
5 | var target: TaskGroup
6 | var append: (_: TaskGroup, _: Task) -> Void
7 |
8 | func performDrop(info: DropInfo) -> Bool {
9 | guard info.hasItemsConforming(to: [TASK_UTI]) else { return false }
10 | guard let origin = source.task else { return false }
11 | let originRemoveOnDrop = source.removeOnDrop
12 | source.stopDrop()
13 |
14 | let sourceTaskGroup = origin.1
15 | let sourceTask = origin.2
16 |
17 | // source and target group are the same, nothing to do
18 | if (sourceTaskGroup.id == target.id) {
19 | return false
20 | }
21 |
22 | originRemoveOnDrop(sourceTask)
23 | append(target, sourceTask)
24 |
25 | return true
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/Colors/ListBackground.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0xFF",
9 | "green" : "0xFF",
10 | "red" : "0xFF"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0x00",
27 | "green" : "0x00",
28 | "red" : "0x00"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Clmn/Util/EmptyNavigationLink.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct EmptyNavigationLink: View {
4 | let lazyDestination: LazyView
5 | let isActive: Binding
6 |
7 | init(
8 | @ViewBuilder destination: @escaping (T) -> Destination,
9 | selection: Binding
10 | ) {
11 | lazyDestination = LazyView(destination(selection.wrappedValue!))
12 | isActive = .init(
13 | get: { selection.wrappedValue != nil },
14 | set: { isActive in
15 | if !isActive {
16 | selection.wrappedValue = nil
17 | }
18 | }
19 | )
20 | }
21 |
22 | var body: some View {
23 | NavigationLink(
24 | destination: lazyDestination,
25 | isActive: isActive,
26 | label: { EmptyView() }
27 | ).buttonStyle(.plain)
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/Clmn/Views/Task/DeleteTaskConfirmationDialog.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct DeleteTaskConfirmationDialog: ViewModifier {
4 | @Binding var deleteIntent: DeleteIntent
5 |
6 | var onCommit: (Task) -> Void
7 |
8 | func body(content: Content) -> some View {
9 | content
10 | .confirmationDialog("Are you sure?", isPresented: $deleteIntent.isPresented) {
11 | Button("Delete Task?", role: .destructive) {
12 | guard (deleteIntent.target != nil) else { return }
13 | onCommit(deleteIntent.target!)
14 | deleteIntent.reset()
15 | }
16 | }
17 | }
18 | }
19 |
20 | extension View {
21 | func deleteTaskConfirmation(_ deleteIntent: Binding>, onCommit: @escaping (Task) -> Void) -> some View {
22 | self.modifier(DeleteTaskConfirmationDialog(deleteIntent: deleteIntent, onCommit: onCommit))
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Clmn/Views/DragDrop/OnDragTaskGroup+DropOnTaskGroup.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct DragTaskGroupDropOnTaskGroup: DropDelegate {
4 | var source: DragTaskGroupModel
5 | var target: (TaskList, TaskGroup)
6 | var reorder: (_: TaskGroup, _: TaskGroup) -> Void
7 |
8 | func performDrop(info: DropInfo) -> Bool {
9 | guard info.hasItemsConforming(to: [TASKGROUP_UTI]) else { return false }
10 | guard let origin = source.group else { return false }
11 | source.stopDrop()
12 |
13 | // accept only groups within the same tasklist
14 | if (origin.0.id != target.0.id) {
15 | return false
16 | }
17 |
18 | let sourceGroup = origin.1
19 | let targetGroup = target.1
20 |
21 | if (sourceGroup.id == targetGroup.id) {
22 | return false
23 | }
24 |
25 | reorder(sourceGroup, targetGroup)
26 |
27 | return true
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/Clmn/Components/SheetHeader.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct SheetHeader: View {
4 | private var title: String
5 |
6 | init(_ title: String) {
7 | self.title = title
8 | }
9 |
10 | var body: some View {
11 | VStack {
12 | HStack(alignment: .center) {
13 | Spacer()
14 | Image(systemName: Icons.formSheetHeader)
15 | Text(title)
16 | .padding([.top, .bottom])
17 | Spacer()
18 | }
19 | .frame(
20 | maxWidth: .infinity
21 | )
22 | .font(Font.App.formSheetHeader)
23 | .colorInvert()
24 | .background(Color.App.formSheetBackground)
25 | }
26 | Spacer().frame(height: 20)
27 | }
28 | }
29 |
30 | struct SheetHeader_Previews: PreviewProvider {
31 | static var previews: some View {
32 | SheetHeader("Hello")
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/Clmn/Library/+Bundle.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import Foundation
3 |
4 | extension Bundle {
5 |
6 | public var appName: String {
7 | i("CFBundleName")
8 | }
9 | public var displayName: String {
10 | i("CFBundleDisplayName")
11 | }
12 | public var language: String {
13 | i("CFBundleDevelopmentRegion")
14 | }
15 | public var identifier: String {
16 | i("CFBundleIdentifier")
17 | }
18 | public var copyright: String {
19 | i("NSHumanReadableCopyright").replacingOccurrences(of: "\\\\n", with: "\n")
20 | }
21 |
22 | /// Returns the build number.
23 | public var appBuild: String {
24 | i("CFBundleVersion")
25 | }
26 | /// Returns the public version number.
27 | public var appVersion: String {
28 | i("CFBundleShortVersionString")
29 | }
30 |
31 | fileprivate func i(_ str: String) -> String {
32 | infoDictionary?[str] as? String ?? "⚠️"
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/Clmn/Models/Task.swift:
--------------------------------------------------------------------------------
1 | import CoreData
2 |
3 | typealias TaskId = UUID
4 |
5 | struct Task: Identifiable, Equatable, Codable {
6 |
7 | var id: TaskId = TaskId()
8 | var name: String
9 | var note: String?
10 | var color: Int = 0
11 | // meta
12 | var completed: Bool = false
13 | var progress: Int = 0
14 | var created: Date = Date.now
15 | var completedAt: Date? = nil
16 |
17 | public static let foo = Task(id: TaskId(), name: "n/a")
18 |
19 | func canceled() -> Bool {
20 | progress == -1
21 | }
22 | func inProgress() -> Bool {
23 | progress > 0
24 | }
25 | func inactive() -> Bool {
26 | completed || progress == -1
27 | }
28 | }
29 |
30 | extension Array where Element == Task {
31 |
32 | func with(_ task: Task, consumer: (Int) -> Void) {
33 | guard let index = firstIndex(where: { t in t.id == task.id } ) else { return }
34 | consumer(index)
35 | }
36 |
37 | }
38 |
--------------------------------------------------------------------------------
/Clmn/Views/Main/DeleteBoardConfirmationDialog.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct DeleteBoardConfirmationDialog: ViewModifier {
4 | @Binding var deleteIntent: DeleteIntent
5 |
6 | var onCommit: (Board) -> Void
7 |
8 | func body(content: Content) -> some View {
9 | content
10 | .confirmationDialog("Delete Board \n\(deleteIntent.target?.name ?? "")?", isPresented: $deleteIntent.isPresented) {
11 | Button("Yes", role: .destructive) {
12 | guard (deleteIntent.target != nil) else { return }
13 | onCommit(deleteIntent.target!)
14 | deleteIntent.reset()
15 | }
16 | }
17 | }
18 | }
19 |
20 | extension View {
21 | func deleteBoardConfirmation(_ deleteIntent: Binding>, onCommit: @escaping (Board) -> Void) -> some View {
22 | self.modifier(DeleteBoardConfirmationDialog(deleteIntent: deleteIntent, onCommit: onCommit))
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Clmn/Views/TaskList/DeleteTaskListConfirmationDialog.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct DeleteTaskListConfirmationDialog: ViewModifier {
4 | @Binding var deleteIntent: DeleteIntent
5 |
6 | var onCommit: (TaskList) -> Void
7 |
8 | func body(content: Content) -> some View {
9 | content
10 | .confirmationDialog("Are you sure?", isPresented: $deleteIntent.isPresented) {
11 | Button("Delete List?", role: .destructive) {
12 | guard (deleteIntent.target != nil) else { return }
13 | onCommit(deleteIntent.target!)
14 | deleteIntent.reset()
15 | }
16 | }
17 | }
18 | }
19 |
20 | extension View {
21 | func deleteTaskListConfirmation(_ deleteIntent: Binding>, onCommit: @escaping (TaskList) -> Void) -> some View {
22 | self.modifier(DeleteTaskListConfirmationDialog(deleteIntent: deleteIntent, onCommit: onCommit))
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Clmn/Views/TaskGroup/DeleteTaskGroupConfirmationDialog.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct DeleteTaskGroupConfirmationDialog: ViewModifier {
4 | @Binding var deleteIntent: DeleteIntent
5 |
6 | var onCommit: (TaskGroup) -> Void
7 |
8 | func body(content: Content) -> some View {
9 | content
10 | .confirmationDialog("Are you sure?", isPresented: $deleteIntent.isPresented) {
11 | Button("Delete Group?", role: .destructive) {
12 | guard (deleteIntent.target != nil) else { return }
13 | onCommit(deleteIntent.target!)
14 | deleteIntent.reset()
15 | }
16 | }
17 | }
18 | }
19 |
20 | extension View {
21 | func deleteTaskGroupConfirmation(_ deleteIntent: Binding>, onCommit: @escaping (TaskGroup) -> Void) -> some View {
22 | self.modifier(DeleteTaskGroupConfirmationDialog(deleteIntent: deleteIntent, onCommit: onCommit))
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Clmn.xcodeproj/xcuserdata/ispasic.xcuserdatad/xcschemes/xcschememanagement.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | SchemeUserState
6 |
7 | Clmn.xcscheme
8 |
9 | orderHint
10 | 0
11 |
12 | Fridge basics (Playground) 1.xcscheme
13 |
14 | isShown
15 |
16 | orderHint
17 | 2
18 |
19 | Fridge basics (Playground) 2.xcscheme
20 |
21 | isShown
22 |
23 | orderHint
24 | 3
25 |
26 | Fridge basics (Playground).xcscheme
27 |
28 | isShown
29 |
30 | orderHint
31 | 1
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/Clmn/Models/TaskGroup.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | typealias TaskGroupId = UUID
4 |
5 | struct TaskGroup: Identifiable, Equatable, Codable {
6 |
7 | var id: TaskGroupId = TaskGroupId()
8 | var name: String
9 | var tasks: [Task] = []
10 |
11 | public static let foo = TaskGroup(id: TaskGroupId(), name: "n/a")
12 | }
13 |
14 | extension Array where Element == TaskGroup {
15 |
16 | func with(_ task: Task, consumer: (Int, Int) -> Void) {
17 | for (groupIndex, group) in enumerated() {
18 | group.tasks.with(task) { taskIndex in
19 | consumer(groupIndex, taskIndex)
20 | return
21 | }
22 | }
23 | }
24 |
25 | func with(_ group: TaskGroup, consumer: (Int) -> Void) {
26 | guard let index = firstIndex(where: { g in g.id == group.id } ) else { return }
27 | consumer(index)
28 | }
29 | }
30 |
31 | extension ArraySlice where Element == TaskGroup {
32 | func isLast(_ group: TaskGroup) -> Bool {
33 | last?.id == group.id
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/Clmn/Views/Main/ZeroBoardsView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct ZeroBoardView: View {
4 | var body: some View {
5 | VStack {
6 | Spacer()
7 | Image("AppBW")
8 | .resizable()
9 | .scaledToFit()
10 | .frame(width: 256, height: 256)
11 | Text("Just Columns & Tasks")
12 | .font(.system(size: 18, weight: .medium))
13 | .foregroundColor(Color.secondary)
14 | Spacer()
15 | HStack(alignment: .center) {
16 | Image(systemName: "arrow.left")
17 | Text("Add some Boards to get started...")
18 | .font(.system(size: 16).italic())
19 | .foregroundColor(Color.secondary)
20 | Spacer()
21 | }
22 | .padding()
23 | .padding(.bottom, 4)
24 | }
25 | }
26 | }
27 |
28 | struct DetailView_Previews: PreviewProvider {
29 | static var previews: some View {
30 | ZeroBoardView()
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/Clmn/UI/Colors.swift:
--------------------------------------------------------------------------------
1 | import CoreData
2 | import SwiftUI
3 |
4 | struct AppColors {
5 | let sidebarBackground = Color("SidebarBackgroundColor")
6 | let sidebarSelected = Color.selectedTextBackground
7 |
8 | let listBackground = Color("ListBackground")
9 | let listSplitLine = Color.windowBackground
10 | let listSubtitle = Color.secondary
11 | let listSelect = Color("ListSelect")
12 | let listText = Color.text
13 | let listOffText = Color("ListOffText")
14 |
15 | let formGray = Color.gray
16 | let formSheetBackground = Color.primary
17 | let formBorders = Color.secondary
18 |
19 | let textInvert = Color("TextInvert")
20 | let textWarn = Color.red
21 | let taskNote = Color("TaskNote")
22 |
23 | let taskCompleted = Color.gray
24 |
25 | let taskColors = [
26 | Color("TaskColor0"),
27 | Color("TaskColor1"),
28 | Color("TaskColor2"),
29 | Color("TaskColor3"),
30 | Color("TaskColor4"),
31 | Color("TaskColor5")
32 | ]
33 | }
34 |
35 | extension Color {
36 | static let App = AppColors()
37 | }
38 |
--------------------------------------------------------------------------------
/Clmn/Views/DragDrop/OnDragTask+DropOnTask.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct DragTaskDropOnTask: DropDelegate {
4 | var source: DragTaskModel
5 | var target: (TaskGroup, Task)
6 | var appendToTarget: (_: Task) -> Void
7 | var reorder: (_: Task, _: Task) -> Void
8 |
9 | func performDrop(info: DropInfo) -> Bool {
10 | guard info.hasItemsConforming(to: [TASK_UTI]) else { return false }
11 | guard let origin = source.task else { return false }
12 | let originRemoveOnDrop = source.removeOnDrop
13 | source.stopDrop()
14 |
15 | let sourceTaskGroup = origin.1
16 | let sourceTask = origin.2
17 |
18 | let targetTaskGroup = target.0
19 | let targetTask = target.1
20 |
21 | if (sourceTask.id == targetTask.id) {
22 | return false
23 | }
24 |
25 | if (sourceTaskGroup.id == targetTaskGroup.id) {
26 | reorder(sourceTask, targetTask)
27 | return true
28 | }
29 |
30 | originRemoveOnDrop(sourceTask)
31 | appendToTarget(sourceTask)
32 |
33 | return true
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/Clmn/Views/DragDrop/Many+DropOnTaskList.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import UniformTypeIdentifiers
3 |
4 | struct DropOnTaskListDispatcher: DropDelegate {
5 | var sourceTask: DragTaskModel?
6 | var sourceList: DragTaskListModel?
7 | var target: TaskList
8 |
9 | var reorderLists: (_: TaskList, _: TaskList) -> Void
10 | var appendTaskToList: (_: Task) -> Void
11 |
12 | func performDrop(info: DropInfo) -> Bool {
13 | if (info.hasItemsConforming(to: [TASK_UTI])) {
14 | guard (sourceTask != nil) else { return false }
15 | return DragTaskDropOnTaskList(
16 | source: sourceTask!,
17 | target: target,
18 | append: appendTaskToList)
19 | .performDrop(info: info)
20 | }
21 | else if (info.hasItemsConforming(to: [TASKLIST_UTI])) {
22 | guard (sourceList != nil) else { return false }
23 | return DragTaskListDropOnTaskList(
24 | source: sourceList!,
25 | target: target,
26 | reorder: reorderLists)
27 | .performDrop(info: info)
28 | }
29 | return false
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/Clmn/Views/TaskGroup/TaskGroupSheet.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// TaskGroup sheet view.
4 | struct TaskGroupSheet: View {
5 | @Environment(\.dismiss) var dismiss
6 |
7 | var group: TaskGroup?
8 | var listVM: TaskListVM
9 |
10 | @State private var groupName = ""
11 |
12 | private func isUpdate() -> Bool {
13 | group != nil
14 | }
15 |
16 | var body: some View {
17 | VStack {
18 | SheetHeader("Group")
19 | VStack {
20 | FormTextField(
21 | text: $groupName,
22 | placeholder: "Group Name...",
23 | imageName: Icons.group)
24 | Spacer()
25 | SheetCancelOk(isUpdate: isUpdate()) {
26 | listVM.addOrUpdateTaskGroup(group: group, groupName)
27 | } onDelete: {
28 | guard isUpdate() else { return }
29 | listVM.deleteTaskGroup(group!)
30 | }
31 | }
32 | .padding()
33 | }
34 | .frame(width: goldenRatio.of(200), height: 200)
35 | .onAppear {
36 | groupName = group?.name ?? ""
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/Clmn/Views/DragDrop/Many+DropOnTaskGroup.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import UniformTypeIdentifiers
3 |
4 | struct DropOnTaskGroupDispatcher: DropDelegate {
5 | var sourceTask: DragTaskModel?
6 | var sourceGroup: DragTaskGroupModel?
7 | var target: (TaskList, TaskGroup)
8 |
9 | var reorderGroups: (_: TaskGroup, _: TaskGroup) -> Void
10 | var appendTaskToGroup: (_: TaskGroup, _: Task) -> Void
11 |
12 | func performDrop(info: DropInfo) -> Bool {
13 | if (info.hasItemsConforming(to: [TASK_UTI])) {
14 | guard (sourceTask != nil) else { return false }
15 | return DragTaskDropOnTaskGroup(
16 | source: sourceTask!,
17 | target: target.1,
18 | append: appendTaskToGroup)
19 | .performDrop(info: info)
20 | }
21 | else if (info.hasItemsConforming(to: [TASKGROUP_UTI])) {
22 | guard (sourceGroup != nil) else { return false }
23 | return DragTaskGroupDropOnTaskGroup(
24 | source: sourceGroup!,
25 | target: target,
26 | reorder: reorderGroups)
27 | .performDrop(info: info)
28 | }
29 | return false
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/Clmn/Views/Board/EmptyBoardView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct EmptyBoardView: View {
4 | @Binding var taskListDetails: ModelOpt?
5 |
6 | var body: some View {
7 | VStack {
8 | Spacer()
9 | Image("AppBW")
10 | .resizable()
11 | .scaledToFit()
12 | .frame(width: 256, height: 256)
13 | Text("Add some Lists for your tasks...")
14 | .font(.system(size: 16).italic())
15 | .foregroundColor(Color.secondary)
16 | Image(systemName: "arrow.down")
17 | .resizable()
18 | .frame(width: 26, height: 26)
19 | .foregroundColor(Color.secondary)
20 | Spacer()
21 | Button(
22 | action: { taskListDetails = ModelOpt.ofNew() },
23 | label: {
24 | Image(systemName: Icons.addList)
25 | Text("Add List")
26 | }
27 | )
28 | .controlSize(.large)
29 | }
30 | .padding()
31 | }
32 | }
33 |
34 | struct EmptyBoardView_Previews: PreviewProvider {
35 | static var previews: some View {
36 | EmptyBoardView(taskListDetails: .constant(ModelOpt.ofNew()))
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Clmn/Views/Settings/BoardSettingsView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct BoardSettingsView: View {
4 |
5 | @AppStorage(SETTINGS_TASK_CHECKBOX_IMAGE) private var taskCheckboxImage = false
6 | @AppStorage(SETTINGS_TASK_SELECTABLE) private var taskSelectable = true
7 |
8 | var body: some View {
9 | VStack(alignment: .leading, spacing: .zero) {
10 | Toggle("Use checkbox icon for completed tasks", isOn: $taskCheckboxImage)
11 | .font(Font.App.formField)
12 | .padding([.top], 20)
13 | FormDescription("When selected, the completed tasks will be marked with a checkbox icon instead of filled square.")
14 | .padding([.leading], 20)
15 |
16 | Toggle("Use dark background for selected task", isOn: $taskSelectable)
17 | .font(Font.App.formField)
18 | .padding([.top], 20)
19 | FormDescription("Enables the dark background of the selected task for better visibility.")
20 | .padding([.leading], 20)
21 |
22 | Spacer()
23 | }.padding()
24 | }
25 | }
26 | struct BoardSettingsView_Previews: PreviewProvider {
27 | static var previews: some View {
28 | BoardSettingsView()
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/Clmn/UI/Typography.swift:
--------------------------------------------------------------------------------
1 | import CoreData
2 | import SwiftUI
3 |
4 | struct Typography {
5 | let sideboard: Font!
6 | let formField: Font!
7 | let formLabel: Font!
8 | let formDescription: Font!
9 | let formFieldFont: NSFont!
10 | let formSheetHeader: Font!
11 | let listTitle: Font!
12 | let listSubtitle: Font!
13 | let groupName: Font!
14 | let taskIcon: Font!
15 | let taskText: Font!
16 | let taskNote: Font!
17 |
18 | init() {
19 | self.sideboard = Font.system(size: 14).weight(.semibold)
20 | self.formField = Font.system(size: 14)
21 | self.formFieldFont = .systemFont(ofSize: 14, weight: .regular)
22 | self.formLabel = Font.system(size: 14)
23 | self.formSheetHeader = Font.system(size: 14).bold()
24 | self.listTitle = Font.custom("PeriodicoDisplay-Bd", size: 32)
25 | self.listSubtitle = Font.system(size: 16, design: .default)
26 | self.groupName = Font.custom("PeriodicoDisplay-Rg", size: 22)
27 | self.taskIcon = Font.system(size: 16, design: .monospaced)
28 | self.taskText = Font.system(size: 16)
29 | self.taskNote = Font.system(size: 14)
30 | self.formDescription = Font.system(size: 12).weight(.light)
31 | }
32 | }
33 |
34 | extension Font {
35 | static let App = Typography()
36 | }
37 |
--------------------------------------------------------------------------------
/Clmn/Library/+Array.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | extension Array where Element: Equatable {
4 |
5 | /// Removes the first element that is equal to the given `object`.
6 | /// Returns the index of removed element or -1
7 | @discardableResult
8 | mutating func removeElement(_ object: Element) -> Int {
9 | guard let index = firstIndex(of: object) else {
10 | return -1
11 | }
12 | remove(at: index)
13 | return index
14 | }
15 |
16 | /// Returns element on the index safely.
17 | func safeGet(_ index: Int) -> Element? {
18 | if (count == 0) {
19 | return nil
20 | }
21 | if (index < 0) {
22 | return first
23 | }
24 | if (index >= endIndex) {
25 | return last
26 | }
27 | return self[index]
28 | }
29 |
30 | /// Returns random element from the array.
31 | func randomElement() -> Element? {
32 | guard !isEmpty else {
33 | return nil
34 | }
35 | let index = Int(arc4random_uniform(UInt32(count)))
36 | return self[index]
37 | }
38 |
39 | /// Consumes array element on given place.
40 | func withElement(_ object: Element, consumer: (_: Int) -> Void) {
41 | guard let index = firstIndex(of: object) else { return }
42 | consumer(index)
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD 2-Clause License
2 |
3 | Copyright (c) 2022, Igor Spasić
4 | All rights reserved.
5 |
6 | Redistribution and use in source and binary forms, with or without
7 | modification, are permitted provided that the following conditions are met:
8 |
9 | 1. Redistributions of source code must retain the above copyright notice, this
10 | list of conditions and the following disclaimer.
11 |
12 | 2. Redistributions in binary form must reproduce the above copyright notice,
13 | this list of conditions and the following disclaimer in the documentation
14 | and/or other materials provided with the distribution.
15 |
16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26 |
--------------------------------------------------------------------------------
/Clmn/Views/TaskList/TaskListButton.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct TaskListButton: View {
4 | var list: TaskList
5 | @Binding var taskListDetails: ModelOpt?
6 | @Binding var hovered: Bool
7 | var isLast: Bool
8 |
9 | var body: some View {
10 | if (hovered) {
11 | HStack {
12 | Spacer()
13 | Button {
14 | taskListDetails = ModelOpt.of(list)
15 | } label: {
16 | TaskListMenuLabel()
17 | }
18 | .buttonStyle(.borderless)
19 | Spacer()
20 | if (isLast) {
21 | Button {
22 | taskListDetails = ModelOpt.ofNew()
23 | } label: {
24 | Image(systemName: Icons.addList)
25 | .resizable()
26 | .frame(width: 18, height: 18)
27 | }
28 | .buttonStyle(.borderless)
29 | .padding(.trailing, 2)
30 | }
31 | }
32 | } else {
33 | Spacer()
34 | .frame(height: 24)
35 | }
36 | }
37 | }
38 |
39 | internal struct TaskListMenuLabel: View {
40 |
41 | internal var body: some View {
42 | Text(Image.init(systemName: Icons.ellipsis).resizable())
43 | .font(.system(size: 20))
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/Clmn/Services/BoardsService.swift:
--------------------------------------------------------------------------------
1 | import os
2 | import Foundation
3 | import Fridge
4 |
5 | class BoardsService {
6 |
7 | private func allBoardsObjectID() -> String {
8 | "clmn.boards"
9 | }
10 |
11 | private static let logger = Logger(
12 | subsystem: Bundle.main.bundleIdentifier!,
13 | category: String(describing: BoardsService.self)
14 | )
15 |
16 | func fetchBoards() -> [Board] {
17 | let objId = allBoardsObjectID()
18 | if (!Fridge.isFrozen🔬(objId)) {
19 | return []
20 | }
21 | let boards: [Board]
22 | do {
23 | boards = try Fridge.unfreeze🪅🎉(objId)
24 | Self.logger.notice("Boards fetched: \(boards.count)")
25 | } catch {
26 | Self.logger.error("Failed to fetch boards: \(error.localizedDescription)")
27 | boards = []
28 | }
29 | return boards
30 | }
31 |
32 | func storeBoards(_ boards: [Board]) {
33 | let objId = allBoardsObjectID()
34 | do {
35 | try Fridge.freeze🧊(boards, id: objId)
36 | Self.logger.notice("Boards stored: \(boards.count)")
37 | } catch {
38 | Self.logger.error("Failed to store boards: \(error.localizedDescription)")
39 | }
40 | }
41 |
42 | func storeBoardsAsync(_ boards: [Board]) {
43 | DispatchQueue.global().async {
44 | self.storeBoards(boards)
45 | }
46 | }
47 |
48 | }
49 |
--------------------------------------------------------------------------------
/Clmn/Services/AppService.swift:
--------------------------------------------------------------------------------
1 | import os
2 | import Foundation
3 | import Fridge
4 |
5 | class AppService {
6 |
7 | private func appObjectID() -> String {
8 | "clmn.app"
9 | }
10 |
11 | private static let logger = Logger(
12 | subsystem: Bundle.main.bundleIdentifier!,
13 | category: String(describing: AppService.self)
14 | )
15 |
16 | func fetchMetadata() -> AppMetaData {
17 | let objId = appObjectID()
18 | if (!Fridge.isFrozen🔬(objId)) {
19 | return AppMetaData(appVersion: 0, dataVersion: 0)
20 | }
21 | let appMetaData: AppMetaData
22 | do {
23 | appMetaData = try Fridge.unfreeze🪅🎉(objId)
24 | Self.logger.notice("App meta-data fetched.")
25 | } catch {
26 | Self.logger.error("Failed to fetch app meta-data: \(error.localizedDescription)")
27 | fatalError("App meta-data failure!")
28 | }
29 | return appMetaData
30 | }
31 |
32 | func storeMetadata(appVersion: Int, dataVersion: Int) {
33 | let appMeta = AppMetaData(appVersion: appVersion, dataVersion: dataVersion)
34 | let objId = appObjectID()
35 | do {
36 | try Fridge.freeze🧊(appMeta, id: objId)
37 | Self.logger.notice("App meta-data stored.")
38 | } catch {
39 | Self.logger.error("Failed to store app meta-data: \(error.localizedDescription)")
40 | }
41 | }
42 |
43 | }
44 |
--------------------------------------------------------------------------------
/Clmn/Models/TaskList.swift:
--------------------------------------------------------------------------------
1 | import CoreData
2 |
3 | typealias TaskListId = UUID
4 |
5 | struct TaskList: Identifiable, Equatable, Codable {
6 |
7 | var id: TaskListId = TaskListId()
8 | var boardId: BoardId
9 | var title: String
10 | var description: String?
11 |
12 | var groups: [TaskGroup] = [TaskGroup(id: TaskGroupId(), name: "*")]
13 |
14 | public static let foo = TaskList(boardId: BoardId(), title: "n/a")
15 |
16 | func appGroups() -> ArraySlice {
17 | groups[1 ..< groups.endIndex]
18 | }
19 |
20 | func defaultGroup() -> TaskGroup {
21 | groups[0]
22 | }
23 |
24 | func valid() -> Bool {
25 | !groups.isEmpty
26 | }
27 |
28 | /// Calculates total tasks.
29 | func totalTasks() -> Int {
30 | groups.reduce(0) { $0 + $1.tasks.count }
31 | }
32 |
33 | func completedTasks() -> Int {
34 | groups.reduce(0) { $0 + $1.tasks.filter{$0.completed}.count }
35 | }
36 |
37 | func progress() -> Float {
38 | Float(completedTasks()) / Float(totalTasks())
39 | }
40 | }
41 |
42 | extension Array where Element == TaskList {
43 |
44 | func with(_ list: TaskList, consumer: (Int) -> Void) {
45 | guard let index = firstIndex(where: { l in l.id == list.id } ) else { return }
46 | consumer(index)
47 | }
48 |
49 | /// Returns `true` if the list is the last one.
50 | func isLast(_ list: TaskList) -> Bool {
51 | last?.id == list.id
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/Clmn/Util/Icons.swift:
--------------------------------------------------------------------------------
1 | struct Icons {
2 |
3 | /// MARK: - app
4 |
5 | static let board = "rectangle.split.3x1"
6 | static let list = "list.bullet.rectangle.portrait"
7 | static let group = "list.bullet.below.rectangle"
8 | static let task = "checkmark.shield"
9 |
10 | static let taskOpen = "square"
11 | static let taskCompleted = "square.fill"
12 | static let taskCompleted2 = "checkmark.square.fill"
13 | static let taskCanceled = "multiply.square.fill"
14 | static let taskProgress1 = "square.lefthalf.filled"
15 | static let taskProgress2 = "square.righthalf.filled"
16 | static let taskLink = "link"
17 | static let taskNote = "doc"
18 | static let taskActions = "chevron.right.2"
19 |
20 | /// MARK: - form
21 |
22 | static let formSheetHeader = "note"
23 | static let formDescription = "character.textbox"
24 |
25 | /// MARK: - misc
26 |
27 | static let sidebarToggle = "sidebar.left"
28 | static let ellipsis = "ellipsis"
29 |
30 | /// MARK: - edit
31 |
32 | static let addTask = "plus"
33 | static let addList = "plus.square"
34 | static let addGroup = "plus.viewfinder"
35 | static let addBoard = "plus.square.fill"
36 | static let edit = "square.and.pencil"
37 | static let delete = "delete.left"
38 |
39 | static let completeTask = "checkmark"
40 | static let cancelTask = "xmark"
41 |
42 | /// MARK: - settings
43 |
44 | static let settingsGeneral = "slider.vertical.3"
45 | static let settingsBehaviour = "wand.and.stars.inverse"
46 |
47 | }
48 |
--------------------------------------------------------------------------------
/DEV.md:
--------------------------------------------------------------------------------
1 | # DEV details
2 |
3 | ## MVVM
4 |
5 | `ViewModel`: an `ObservableObject` that encapsulates the business logic and allows the `View` to observe changes of the state.
6 |
7 | When the `View` appears on the screen, the `onAppear` callback calls `loadCountries()` on the `ViewModel`, triggering the networking call for loading the data inside WebService. `ViewModel` receives the data in the callback and pushes the updates through `@Published` variable countries, observed by the `View`.
8 |
9 | 
10 |
11 | Each view should have its own `ViewModel`. But there is no need to model the entire hierarchy. Only what the view needs.
12 |
13 | + The Model-oriented View Model (MVM), while low in code duplication, is a nightmare to maintain.
14 | + The View-oriented View Model (VVM) produces highly-specialised classes for each view, but contains duplicates.
15 |
16 | Having one VM per View is easier to maintain and code for.
17 |
18 | ## NavigationList
19 |
20 | `NavigationList` in the sub-board is a bad component. The _only_ way to make it work is to use `List` and `ForEach` together. You could use just `List` to iterate, but there is no way to use drag-n-drop, as it breaks UI behavior. When used together, you get the `onMove()` on `ForEach` that is the only way to use drag-n-drop.
21 |
22 | ## Themes
23 |
24 | This is not working:
25 |
26 | ```swift
27 | .environment(\.colorScheme, appThemeSetting.appTheme(colorScheme))
28 | ```
29 |
30 | Sheets, for example, are not getting themed.
31 |
32 |
33 | ## Don't use `Form`.
34 |
35 | `Form` changes elements behavior and look. Example: the placeholder for a `TextField`.
36 |
37 |
--------------------------------------------------------------------------------
/Clmn/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | },
6 | "images" : [
7 | {
8 | "idiom" : "mac",
9 | "scale" : "1x",
10 | "filename" : "icon_16x16.png",
11 | "size" : "16x16"
12 | },
13 | {
14 | "scale" : "2x",
15 | "size" : "16x16",
16 | "filename" : "icon_16x16@2x.png",
17 | "idiom" : "mac"
18 | },
19 | {
20 | "size" : "32x32",
21 | "scale" : "1x",
22 | "filename" : "icon_32x32.png",
23 | "idiom" : "mac"
24 | },
25 | {
26 | "size" : "32x32",
27 | "filename" : "icon_32x32@2x.png",
28 | "idiom" : "mac",
29 | "scale" : "2x"
30 | },
31 | {
32 | "size" : "128x128",
33 | "scale" : "1x",
34 | "filename" : "icon_128x128.png",
35 | "idiom" : "mac"
36 | },
37 | {
38 | "scale" : "2x",
39 | "size" : "128x128",
40 | "idiom" : "mac",
41 | "filename" : "icon_128x128@2x.png"
42 | },
43 | {
44 | "scale" : "1x",
45 | "idiom" : "mac",
46 | "size" : "256x256",
47 | "filename" : "icon_256x256.png"
48 | },
49 | {
50 | "idiom" : "mac",
51 | "filename" : "icon_256x256@2x.png",
52 | "scale" : "2x",
53 | "size" : "256x256"
54 | },
55 | {
56 | "scale" : "1x",
57 | "idiom" : "mac",
58 | "size" : "512x512",
59 | "filename" : "icon_512x512.png"
60 | },
61 | {
62 | "scale" : "2x",
63 | "size" : "512x512",
64 | "filename" : "icon_512x512@2x.png",
65 | "idiom" : "mac"
66 | }
67 | ]
68 | }
--------------------------------------------------------------------------------
/Clmn/Views/Task/TaskSheet.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// Task sheet view.
4 | struct TaskSheet: View {
5 | @Environment(\.dismiss) var dismiss
6 |
7 | var task: Task?
8 | var group: TaskGroup
9 | var listVM: TaskListVM
10 |
11 | @State private var name = ""
12 | @State private var note = ""
13 | @State private var color = 0
14 |
15 | private func isUpdate() -> Bool {
16 | task != nil
17 | }
18 |
19 | var body: some View {
20 | VStack {
21 | SheetHeader("Task")
22 | VStack {
23 | FormTextEditor(
24 | text: $name,
25 | placeholder: "Task...",
26 | imageName: Icons.task,
27 | height: 46)
28 | FormTextEditor(
29 | text: $note,
30 | placeholder: "Note...",
31 | imageName: Icons.formDescription)
32 | TaskColorRadioButtons(selectedColor: $color)
33 | Spacer()
34 | SheetCancelOk(isUpdate: isUpdate()) {
35 | listVM.addOrUpdateTask(group: group, task: task, name: name, note: note, color: color)
36 | } onDelete: {
37 | guard isUpdate() else { return }
38 | listVM.deleteTask(task!)
39 | }
40 | }
41 | .padding()
42 | }
43 | .frame(width: 360, height: 380)
44 | .onAppear {
45 | name = task?.name ?? ""
46 | color = task?.color ?? 0
47 | note = task?.note ?? ""
48 | }
49 | }
50 | }
51 |
52 |
--------------------------------------------------------------------------------
/Clmn/Views/Board/BoardSheet.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// Simple board sheet view.
4 | struct BoardSheet: View {
5 | @Environment(\.dismiss) var dismiss
6 |
7 | var board: Board?
8 | var allBoardsVM: AllBoardsVM
9 |
10 | @Binding var selectedBoard: Board?
11 |
12 | @State private var boardName: String = ""
13 |
14 | private func isUpdate() -> Bool {
15 | board != nil
16 | }
17 |
18 | var body: some View {
19 | VStack {
20 | SheetHeader("Board")
21 | VStack {
22 | FormTextField(
23 | text: $boardName,
24 | placeholder: "Board Title...",
25 | imageName: Icons.board)
26 | Spacer()
27 | SheetCancelOk(isUpdate: isUpdate()) {
28 | allBoardsVM.addOrUpdateBoard(board: board, name: boardName)
29 | if (isUpdate()) {
30 | // need to update the selected board as its content is updated :)
31 | selectedBoard = allBoardsVM.findBoardById(board!.id)
32 | } else {
33 | selectedBoard = allBoardsVM.boards.last
34 | }
35 | } onDelete: {
36 | guard isUpdate() else { return }
37 | let removedIndex = allBoardsVM.deleteBoard(board!)
38 | selectedBoard = allBoardsVM.boards.safeGet(removedIndex)
39 | }
40 | }
41 | .padding()
42 | }
43 | .frame(width: goldenRatio.of(200), height: 200)
44 | .onAppear {
45 | boardName = board?.name ?? ""
46 | }
47 | }
48 | }
49 |
50 |
--------------------------------------------------------------------------------
/Clmn/Components/Form/FormTextField.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct FormTextField: View {
4 |
5 | @Binding var text: String
6 | var placeholder: String
7 | let imageName: String
8 |
9 | @Environment(\.colorScheme) var colorScheme
10 |
11 | init(text: Binding, placeholder: String, imageName: String) {
12 | self._text = text
13 | self.placeholder = placeholder
14 | self.imageName = imageName
15 | }
16 |
17 | var body: some View {
18 | HStack {
19 | Image(systemName: imageName)
20 | .resizable()
21 | .scaledToFit()
22 | .frame(width: 16, height: 16)
23 | .padding([.vertical], 9)
24 | .padding(.leading, 16)
25 | .foregroundColor(Color.App.formGray)
26 | MacTextEditor(
27 | placeholderText: placeholder,
28 | placeholderColor: Color.App.formGray,
29 | text: $text,
30 | singleLine: true,
31 | font: Font.App.formFieldFont
32 | )
33 | .padding([.top], 6)
34 | }
35 | .cornerRadius(5)
36 | .overlay(
37 | RoundedRectangle(cornerRadius: 5)
38 | .strokeBorder(Color.App.formBorders, lineWidth: 1/3)
39 | .opacity(0.5)
40 | )
41 | .frame(height: 30)
42 | .foregroundColor(colorScheme == .dark ? .white : .black)
43 | }
44 | }
45 |
46 | struct FormTextField_Previews: PreviewProvider {
47 | static var previews: some View {
48 | FormTextField(
49 | text: .constant(""),
50 | placeholder: "Yes",
51 | imageName: "line.3.horizontal")
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/Clmn/Views/Task/AddTaskButtons.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct AddTaskButtons: View {
4 |
5 | @Binding var hovered: Bool
6 | var action: () -> Void
7 | var showAction2: Bool = false
8 | var action2: () -> Void = { }
9 |
10 | @State private var isHovering: Bool = false;
11 |
12 | var body: some View {
13 | HStack(alignment: .center, spacing: 0) {
14 | if (hovered) {
15 | Spacer()
16 |
17 | Button(
18 | action: action,
19 | label: {
20 | Image(systemName: Icons.addTask)
21 | .resizable()
22 | .scaledToFit()
23 | .frame(width: 14, height: 14)
24 | }
25 | )
26 | .buttonStyle(.borderless)
27 | .padding(.trailing)
28 |
29 | if (showAction2) {
30 | Button(
31 | action: action2,
32 | label: {
33 | Image(systemName: "plus.viewfinder")
34 | .resizable()
35 | .scaledToFit()
36 | .frame(width: 16, height: 16)
37 | }
38 | )
39 | .buttonStyle(.borderless)
40 | .padding(.trailing)
41 | }
42 |
43 | Spacer()
44 | }
45 | else {
46 | Spacer().frame(height: 14)
47 | }
48 | }
49 | .padding(.top, 10)
50 | }
51 | }
52 |
53 | struct AddTaskButton_Previews: PreviewProvider {
54 | static var previews: some View {
55 | AddTaskButtons(hovered: .constant(false), action: {})
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/Clmn/Library/+String.swift:
--------------------------------------------------------------------------------
1 | import AppKit
2 |
3 | extension String {
4 | /// Empty string.
5 | static let empty: String = ""
6 |
7 | /// Parses input text as Markdown.
8 | func markdown() -> AttributedString {
9 | do {
10 | var attributedString = try AttributedString(markdown: self,
11 | options: AttributedString.MarkdownParsingOptions(
12 | allowsExtendedAttributes: true,
13 | interpretedSyntax: .inlineOnlyPreservingWhitespace))
14 |
15 | let runs = attributedString.runs
16 | for run in runs {
17 | let range = run.range
18 | // if let textStyle = run.inlinePresentationIntent {
19 | // if textStyle.contains(.emphasized) {
20 | // // change foreground color of bold text
21 | // attributedString[range].foregroundColor = .green
22 | // }
23 | // }
24 | if run.link != nil {
25 | var container = AttributeContainer()
26 | container.foregroundColor = NSColor.fromRGB(red: 33, green: 150, blue: 243)
27 | attributedString[range].setAttributes(container)
28 | }
29 | }
30 | return attributedString
31 | } catch {
32 | return AttributedString(self)
33 | }
34 | }
35 |
36 | /// Trims whitespaces.
37 | func trim() -> String {
38 | trimmingCharacters(in: .whitespacesAndNewlines)
39 | }
40 |
41 | /// Turns String to nil if empty.
42 | func nilIfEmpty() -> String? {
43 | (count == 0) ? nil : self
44 | }
45 |
46 | static func trimAndNil(_ str: String?) -> String? {
47 | str != nil ? str!.trim().nilIfEmpty() : nil
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/Clmn/Services/TaskListsService.swift:
--------------------------------------------------------------------------------
1 | import os
2 | import Foundation
3 | import Fridge
4 |
5 | class TaskListsService {
6 |
7 | private func boardObjectID(_ boardId: BoardId) -> String {
8 | "clmn.board.\(boardId.uuidString)"
9 | }
10 |
11 | private static let logger = Logger(
12 | subsystem: Bundle.main.bundleIdentifier!,
13 | category: String(describing: TaskListsService.self)
14 | )
15 |
16 | func fetchBoardLists(_ boardId: BoardId) -> [TaskList] {
17 | let objId = boardObjectID(boardId)
18 | if (!Fridge.isFrozen🔬(objId)) {
19 | return []
20 | }
21 | let lists: [TaskList]
22 | do {
23 | lists = try Fridge.unfreeze🪅🎉(objId)
24 | Self.logger.notice("Board fetched: \(boardId)")
25 | } catch {
26 | Self.logger.error("Failed to fetch board: \(error.localizedDescription)")
27 | lists = [TaskList(boardId: boardId, title: "Error")]
28 | }
29 |
30 | // make sure that all lists are valid
31 | return lists.filter { $0.valid() }
32 | }
33 |
34 | func storeBoardLists(boardId: BoardId, boardTaskList: [TaskList]) {
35 | let objId = boardObjectID(boardId)
36 | do {
37 | try Fridge.freeze🧊(boardTaskList, id: objId)
38 | Self.logger.notice("Boards stored: \(boardId)")
39 | } catch {
40 | Self.logger.error("Failed to store boards: \(error.localizedDescription)")
41 | }
42 | }
43 |
44 | func storeBoardListsAsync(boardId: BoardId, boardTaskList: [TaskList]) {
45 | DispatchQueue.global().async {
46 | self.storeBoardLists(boardId: boardId, boardTaskList: boardTaskList)
47 | }
48 | }
49 |
50 | func dropBoardLists(_ board: Board) {
51 | Fridge.drop🗑(boardObjectID(board.id))
52 | }
53 |
54 | }
55 |
--------------------------------------------------------------------------------
/Clmn/Views/Board/BoardView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct BoardView: View {
4 | var board: Board
5 | @Binding var taskListDetails: ModelOpt?
6 | @Binding var selectedTask: Task?
7 |
8 | @StateObject var allListsVM = AllTaskListsVM()
9 |
10 | var body: some View {
11 | #if DEBUG
12 | let _ = Self._printChanges()
13 | #endif
14 |
15 | VStack(spacing: 0) {
16 | if (allListsVM.lists.isEmpty) {
17 | EmptyBoardView(taskListDetails: $taskListDetails)
18 | } else {
19 | HStack(
20 | alignment: .top,
21 | spacing: 2
22 | ) {
23 | ForEach(allListsVM.lists, id: \.id) { list in
24 | TaskListView(
25 | allListsVM: allListsVM,
26 | listVM: TaskListVM(list),
27 | selectedTask: $selectedTask
28 | )
29 | .frame(minWidth: 200, minHeight: 200)
30 | .shadow(color: Color.App.listSplitLine, radius: 5, x: 15, y: 15)
31 | }
32 | }
33 | }
34 | }
35 | .sheet(item: $taskListDetails) { item in
36 | TaskListSheet(list: item.model, allListsVM: allListsVM)
37 | }
38 | .onAppear {
39 | allListsVM.loadLists(board: board)
40 | }
41 | .onDisappear {
42 | allListsVM.handleListChanges {
43 | allListsVM.saveLists()
44 | }
45 | }
46 | .onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification), perform: { output in
47 | allListsVM.handleListChanges {
48 | allListsVM.saveLists()
49 | }
50 | })
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/Clmn/Components/Form/FormTextEditor.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct FormTextEditor: View {
4 |
5 | @Binding var text: String
6 | var placeholder: String
7 | let imageName: String
8 | let height: Int
9 |
10 | @Environment(\.colorScheme) var colorScheme
11 |
12 | init(text: Binding, placeholder: String, imageName: String, height: Int = 120) {
13 | self._text = text
14 | self.placeholder = placeholder
15 | self.imageName = imageName
16 | self.height = height
17 | }
18 |
19 | var body: some View {
20 | HStack {
21 | VStack {
22 | Image(systemName: imageName)
23 | .resizable()
24 | .scaledToFit()
25 | .frame(width: 16, height: 16)
26 | .padding([.vertical], 8)
27 | .padding(.leading, 16)
28 | .foregroundColor(Color.App.formGray)
29 | Spacer()
30 | }
31 | MacTextEditor(
32 | placeholderText: placeholder,
33 | placeholderColor: Color.App.formGray,
34 | text: $text,
35 | font: Font.App.formFieldFont
36 | )
37 | .padding([.top,.bottom], 6)
38 | }
39 | .cornerRadius(5)
40 | .overlay(
41 | RoundedRectangle(cornerRadius: 5)
42 | .strokeBorder(Color.App.formBorders, lineWidth: 1/3)
43 | .opacity(0.5)
44 | )
45 | .font(Font.App.formField)
46 | .frame(height: CGFloat(height))
47 | .foregroundColor(colorScheme == .dark ? .white : .black)
48 | }
49 | }
50 |
51 | struct FormTextEditor_Previews: PreviewProvider {
52 | static var previews: some View {
53 | FormTextEditor(
54 | text: .constant(""),
55 | placeholder: "Yes",
56 | imageName: "line.3.horizontal")
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/Clmn.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "pins" : [
3 | {
4 | "identity" : "bsoncoder",
5 | "kind" : "remoteSourceControl",
6 | "location" : "https://github.com/vexy/bsoncoder",
7 | "state" : {
8 | "branch" : "main",
9 | "revision" : "838e4e78aeb3a0886c9478812b7bb6ed59d269c5"
10 | }
11 | },
12 | {
13 | "identity" : "fridge",
14 | "kind" : "remoteSourceControl",
15 | "location" : "https://github.com/vexy/Fridge",
16 | "state" : {
17 | "branch" : "main",
18 | "revision" : "d55e52bb4777047fb5b66b3b8e98568cb6d4eabd"
19 | }
20 | },
21 | {
22 | "identity" : "swift-atomics",
23 | "kind" : "remoteSourceControl",
24 | "location" : "https://github.com/apple/swift-atomics.git",
25 | "state" : {
26 | "revision" : "919eb1d83e02121cdb434c7bfc1f0c66ef17febe",
27 | "version" : "1.0.2"
28 | }
29 | },
30 | {
31 | "identity" : "swift-extras-base64",
32 | "kind" : "remoteSourceControl",
33 | "location" : "https://github.com/swift-extras/swift-extras-base64",
34 | "state" : {
35 | "revision" : "778e00dd7cc2b7970742f061cffc87dd570e6bfa",
36 | "version" : "0.5.0"
37 | }
38 | },
39 | {
40 | "identity" : "swift-extras-json",
41 | "kind" : "remoteSourceControl",
42 | "location" : "https://github.com/swift-extras/swift-extras-json",
43 | "state" : {
44 | "revision" : "122b9454ef01bf89a4c190b8fd3717ddd0a2fbd0",
45 | "version" : "0.6.0"
46 | }
47 | },
48 | {
49 | "identity" : "swift-nio",
50 | "kind" : "remoteSourceControl",
51 | "location" : "https://github.com/apple/swift-nio",
52 | "state" : {
53 | "revision" : "b4e0a274f7f34210e97e2f2c50ab02a10b549250",
54 | "version" : "2.41.1"
55 | }
56 | }
57 | ],
58 | "version" : 2
59 | }
60 |
--------------------------------------------------------------------------------
/Clmn/Components/SheetCancelOk.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct SheetCancelOk: View {
4 | @Environment(\.dismiss) var dismiss
5 |
6 | var isUpdate: Bool = false
7 | var onOk: () -> Void
8 | var onDelete: () -> Void = {}
9 |
10 | @State private var areYouSure: Bool = false
11 |
12 | var body: some View {
13 | HStack {
14 | if (isUpdate) {
15 | Button(role: .destructive, action: {
16 | if (areYouSure == false) {
17 | areYouSure = true
18 | } else {
19 | withAnimation {
20 | onDelete()
21 | }
22 | dismiss()
23 | }
24 | }, label: {
25 | Text(areYouSure ? "DELETE?" : "Delete")
26 | .frame(maxWidth: 60)
27 | .foregroundColor(Color.App.textWarn)
28 | })
29 | }
30 |
31 | Spacer()
32 |
33 | Button(role: .cancel, action: {
34 | dismiss()
35 | }, label: {
36 | Text("Cancel")
37 | .frame(maxWidth: 60)
38 | })
39 | .keyboardShortcut(.cancelAction)
40 |
41 | Button(action: {
42 | withAnimation(if: !isUpdate) {
43 | onOk()
44 | }
45 | dismiss()
46 | }, label: {
47 | Text("OK")
48 | .frame(maxWidth: 60)
49 | })
50 | //.keyboardShortcut(.defaultAction)
51 | .keyboardShortcut(.return, modifiers: .command)
52 | .tint(.accentColor)
53 | }
54 | .padding(.top, 10)
55 | }
56 | }
57 |
58 | struct SheetCancelOk_Previews: PreviewProvider {
59 | static var previews: some View {
60 | SheetCancelOk(onOk: {}, onDelete: {})
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/Clmn/Util/ForEachWithIndex.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// Cool, extended version of ForEach that includes loop index.
4 | public struct ForEachWithIndex: View {
5 | public var data: Data
6 | public var content: (_ index: Data.Index, _ element: Data.Element) -> Content
7 | var id: KeyPath
8 |
9 | public init(_ data: Data, id: KeyPath, content: @escaping (_ index: Data.Index, _ element: Data.Element) -> Content) {
10 | self.data = data
11 | self.id = id
12 | self.content = content
13 | }
14 |
15 | public var body: some View {
16 | ForEach(
17 | zip(self.data.indices, self.data).map { index, element in
18 | IndexInfo(
19 | index: index,
20 | id: self.id,
21 | element: element
22 | )
23 | },
24 | id: \.elementID
25 | ) { indexInfo in
26 | self.content(indexInfo.index, indexInfo.element)
27 | }
28 | }
29 | }
30 |
31 | extension ForEachWithIndex where ID == Data.Element.ID, Content: View, Data.Element: Identifiable {
32 | public init(_ data: Data, @ViewBuilder content: @escaping (_ index: Data.Index, _ element: Data.Element) -> Content) {
33 | self.init(data, id: \.id, content: content)
34 | }
35 | }
36 |
37 | extension ForEachWithIndex: DynamicViewContent where Content: View {
38 | }
39 |
40 | private struct IndexInfo: Hashable {
41 | let index: Index
42 | let id: KeyPath
43 | let element: Element
44 |
45 | var elementID: ID {
46 | self.element[keyPath: self.id]
47 | }
48 |
49 | static func == (_ lhs: IndexInfo, _ rhs: IndexInfo) -> Bool {
50 | lhs.elementID == rhs.elementID
51 | }
52 |
53 | func hash(into hasher: inout Hasher) {
54 | self.elementID.hash(into: &hasher)
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/Clmn/Util/SideBarUtil.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | class SideBarUtil {
4 | static func toggleSidebar() {
5 | NSApp.keyWindow?.firstResponder?.tryToPerform(#selector(NSSplitViewController.toggleSidebar(_:)), with: nil)
6 | }
7 | }
8 |
9 | struct SplitViewAccessor: NSViewRepresentable {
10 | @Binding var sideCollapsed: Bool
11 | @Binding var onInit: (Bool) -> Void
12 |
13 | func makeNSView(context: Context) -> some NSView {
14 | let view = MyView()
15 | view.sideCollapsed = _sideCollapsed
16 | view.onInit = _onInit
17 | return view
18 | }
19 |
20 | func updateNSView(_ nsView: NSViewType, context: Context) {
21 | }
22 |
23 | class MyView: NSView {
24 | var sideCollapsed: Binding?
25 | var onInit: Binding<(Bool) -> Void>?
26 |
27 | weak private var controller: NSSplitViewController?
28 | private var observer: Any?
29 |
30 | override func viewDidMoveToWindow() {
31 | super.viewDidMoveToWindow()
32 | var superView = self.superview
33 |
34 | // find split view through hierarchy
35 | while superView != nil, !superView!.isKind(of: NSSplitView.self) {
36 | superView = superView?.superview
37 | }
38 | guard let sview = superView as? NSSplitView else { return }
39 |
40 | controller = sview.delegate as? NSSplitViewController // delegate is our controller
41 | if let sideBar = controller?.splitViewItems.first { // now observe for state
42 | let initState = sideBar.isCollapsed
43 | sideCollapsed?.wrappedValue = initState
44 | onInit?.wrappedValue(initState);
45 | observer = sideBar.observe(\.isCollapsed, options: [.new]) { [weak self] _, change in
46 | if let value = change.newValue {
47 | self?.sideCollapsed?.wrappedValue = value // << here !!
48 | }
49 | }
50 | }
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/Clmn/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | ATSApplicationFontsPath
6 | Fonts
7 | CFBundleURLTypes
8 |
9 |
10 | CFBundleTypeRole
11 | Viewer
12 | CFBundleURLIconFile
13 | URLIcon
14 | CFBundleURLName
15 | studio.oblac.clmn
16 | CFBundleURLSchemes
17 |
18 | clmn
19 |
20 |
21 |
22 | UTExportedTypeDeclarations
23 |
24 |
25 | UTTypeConformsTo
26 |
27 | public.text
28 |
29 | UTTypeDescription
30 | Clmn Board item
31 | UTTypeIcons
32 |
33 | UTTypeIdentifier
34 | studio.oblac.clmn.board
35 | UTTypeTagSpecification
36 |
37 |
38 |
39 | UTTypeConformsTo
40 |
41 | public.text
42 |
43 | UTTypeDescription
44 | Clmn Task List item
45 | UTTypeIcons
46 |
47 | UTTypeIdentifier
48 | studio.oblac.clmn.list
49 | UTTypeTagSpecification
50 |
51 |
52 |
53 | UTTypeConformsTo
54 |
55 | public.text
56 |
57 | UTTypeDescription
58 | Clmn Task Group item
59 | UTTypeIcons
60 |
61 | UTTypeIdentifier
62 | studio.oblac.clmn.group
63 | UTTypeTagSpecification
64 |
65 |
66 |
67 | UTTypeConformsTo
68 |
69 | public.text
70 |
71 | UTTypeDescription
72 | Clmn Task item
73 | UTTypeIcons
74 |
75 | UTTypeIdentifier
76 | studio.oblac.clmn.task
77 | UTTypeTagSpecification
78 |
79 |
80 |
81 |
82 |
83 |
--------------------------------------------------------------------------------
/Clmn/Views/TaskGroup/TaskGroupView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct TaskGroupView: View {
4 | var group: TaskGroup
5 | @Binding var taskGroupDetails: ModelOpt?
6 | @Binding var deleteTaskGroup: DeleteIntent
7 | @Binding var hovered: Bool
8 |
9 | var body: some View {
10 | VStack(spacing: 0) {
11 | HStack {
12 | Text(group.name)
13 | .font(Font.App.groupName)
14 | Spacer()
15 | if (hovered) {
16 | Button(
17 | action: { taskGroupDetails = ModelOpt.of(group) },
18 | label: {
19 | Image(systemName: Icons.ellipsis)
20 | .frame(width: 18, height: 18)
21 | .contentShape(Rectangle())
22 | }
23 | )
24 | .buttonStyle(.borderless)
25 | .padding(.top, 4)
26 | .padding(.trailing, 6)
27 | }
28 | }
29 | .contentShape(Rectangle())
30 | .contextMenu {
31 | Button {
32 | taskGroupDetails = ModelOpt.of(group)
33 | } label: {
34 | Label("Edit Group", systemImage: Icons.edit)
35 | .labelStyle(.titleAndIcon)
36 | }
37 | Button(role: .destructive) {
38 | deleteTaskGroup.set(group)
39 | } label: {
40 | Label("Delete Group", systemImage: Icons.delete)
41 | .labelStyle(.titleAndIcon)
42 | }
43 | Divider()
44 | Button {
45 | taskGroupDetails = ModelOpt.ofNew()
46 | } label: {
47 | Label("Add Group", systemImage: Icons.addGroup)
48 | .labelStyle(.titleAndIcon)
49 | }
50 | Button {
51 | //taskDetails = ModelPairOpt.ofNew(list.defaultGroup())
52 | } label: {
53 | Label("Add Task", systemImage: Icons.addTask)
54 | .labelStyle(.titleAndIcon)
55 | }
56 | }
57 | Divider().padding(.bottom, 6)
58 | }
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/Clmn/Views/TaskList/TaskListSheet.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct TaskListSheet: View {
4 | @Environment(\.dismiss) var dismiss
5 |
6 | var list: TaskList?
7 | var allListsVM: AllTaskListsVM
8 | var listVM: TaskListVM? = nil
9 |
10 | var onSave: () -> Void = {}
11 |
12 | @State private var title = ""
13 | @State private var description = ""
14 |
15 | private func isUpdate() -> Bool {
16 | list != nil
17 | }
18 |
19 | var body: some View {
20 | VStack {
21 | SheetHeader("List")
22 | VStack {
23 | FormTextField(
24 | text: $title,
25 | placeholder: "List Title...",
26 | imageName: Icons.list)
27 | FormTextEditor(
28 | text: $description,
29 | placeholder: "Description",
30 | imageName: Icons.formDescription
31 | )
32 | if (isUpdate()) {
33 | Text("Completed **\(list!.completedTasks())** of **\(list!.totalTasks())** tasks.".markdown())
34 | .padding()
35 | HStack {
36 | Button {
37 | guard listVM != nil else { return }
38 | // TODO Improve this!
39 | listVM!.deleteCompletedTasks()
40 | listVM!.deleteCanceledTasks()
41 | allListsVM.apply(from: listVM!.list)
42 | } label: {
43 | Text("Delete inactive tasks")
44 | }
45 | }
46 | }
47 | Spacer()
48 | Divider()
49 | SheetCancelOk(isUpdate: isUpdate()) {
50 | allListsVM.addOrUpdateList(list: list, title, description)
51 | if (listVM != nil) {
52 | listVM!.load(from: allListsVM.lists)
53 | }
54 | } onDelete: {
55 | guard isUpdate() else { return }
56 | allListsVM.deleteList(list!)
57 | }
58 | }
59 | .padding()
60 | }
61 | .frame(width: 360, height: 400)
62 | .onAppear {
63 | title = list?.title ?? ""
64 | description = list?.description ?? ""
65 | }
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/Clmn/Library/+View+RoundedCorners.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | extension View {
4 | func roundedCorners(_ radius: CGFloat, corners: RectCorner) -> some View {
5 | clipShape(RoundedCornerShape(radius: radius, corners: corners))
6 | }
7 | }
8 |
9 | // defines OptionSet, which corners to be rounded – same as UIRectCorner
10 | struct RectCorner: OptionSet {
11 | let rawValue: Int
12 |
13 | static let topLeft = RectCorner(rawValue: 1 << 0)
14 | static let topRight = RectCorner(rawValue: 1 << 1)
15 | static let bottomRight = RectCorner(rawValue: 1 << 2)
16 | static let bottomLeft = RectCorner(rawValue: 1 << 3)
17 |
18 | static let allCorners: RectCorner = [.topLeft, topRight, .bottomLeft, .bottomRight]
19 | }
20 |
21 | struct RoundedCornerShape: Shape {
22 |
23 | var radius: CGFloat = .zero
24 | var corners: RectCorner = .allCorners
25 |
26 | func path(in rect: CGRect) -> Path {
27 | var path = Path()
28 |
29 | let p1 = CGPoint(x: rect.minX, y: corners.contains(.topLeft) ? rect.minY + radius : rect.minY )
30 | let p2 = CGPoint(x: corners.contains(.topLeft) ? rect.minX + radius : rect.minX, y: rect.minY )
31 |
32 | let p3 = CGPoint(x: corners.contains(.topRight) ? rect.maxX - radius : rect.maxX, y: rect.minY )
33 | let p4 = CGPoint(x: rect.maxX, y: corners.contains(.topRight) ? rect.minY + radius : rect.minY )
34 |
35 | let p5 = CGPoint(x: rect.maxX, y: corners.contains(.bottomRight) ? rect.maxY - radius : rect.maxY )
36 | let p6 = CGPoint(x: corners.contains(.bottomRight) ? rect.maxX - radius : rect.maxX, y: rect.maxY )
37 |
38 | let p7 = CGPoint(x: corners.contains(.bottomLeft) ? rect.minX + radius : rect.minX, y: rect.maxY )
39 | let p8 = CGPoint(x: rect.minX, y: corners.contains(.bottomLeft) ? rect.maxY - radius : rect.maxY )
40 |
41 |
42 | path.move(to: p1)
43 | path.addArc(tangent1End: CGPoint(x: rect.minX, y: rect.minY),
44 | tangent2End: p2,
45 | radius: radius)
46 | path.addLine(to: p3)
47 | path.addArc(tangent1End: CGPoint(x: rect.maxX, y: rect.minY),
48 | tangent2End: p4,
49 | radius: radius)
50 | path.addLine(to: p5)
51 | path.addArc(tangent1End: CGPoint(x: rect.maxX, y: rect.maxY),
52 | tangent2End: p6,
53 | radius: radius)
54 | path.addLine(to: p7)
55 | path.addArc(tangent1End: CGPoint(x: rect.minX, y: rect.maxY),
56 | tangent2End: p8,
57 | radius: radius)
58 | path.closeSubpath()
59 |
60 | return path
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/Clmn/ViewModels/AllBoardsViewModel.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | class AllBoardsVM: ObservableObject {
4 |
5 | @Published private(set) var boards: [Board] = []
6 |
7 | func loadBoards() {
8 | boards = services.boards.fetchBoards()
9 | }
10 |
11 | func saveBoards() {
12 | services.boards.storeBoards(boards)
13 | }
14 |
15 | /// Returns `true` if no board is loaded
16 | func isEmpty() -> Bool {
17 | boards.isEmpty
18 | }
19 |
20 | func findBoardById(_ id: BoardId) -> Board? {
21 | boards.first(where: { b in b.id == id })
22 | }
23 |
24 | /// Creates a new board.
25 | @discardableResult
26 | func addNewBoard(_ name: String) -> Board {
27 | let newBoard = Board(
28 | name: name.trim()
29 | )
30 | boards.append(newBoard)
31 | saveBoards()
32 | return newBoard
33 | }
34 |
35 | /// Deletes a board. Returns index of removed element.
36 | @discardableResult
37 | func deleteBoard(_ boardToDelete: Board) -> Int {
38 | let removedIndex = boards.removeElement(boardToDelete)
39 | saveBoards()
40 | return removedIndex
41 | }
42 |
43 | /// Updates the board.
44 | private func updateBoard(_ boardToUpdate: Board, _ name: String) {
45 | boards.with(boardToUpdate) { i in
46 | boards[i].name = name.trim()
47 | }
48 | saveBoards()
49 | }
50 |
51 | /// Adds or updates the board.
52 | func addOrUpdateBoard(board: Board?, name: String) {
53 | if (board == nil) {
54 | addNewBoard(name)
55 | } else {
56 | updateBoard(board!, name)
57 | }
58 | }
59 |
60 | // ---------------------------------------------------------------- reoreder
61 |
62 | func reorder(from: Board, to destination: Board) {
63 | let fromIndex = boards.firstIndex(of: from)
64 | let toIndex = boards.firstIndex(of: destination)
65 | guard (fromIndex != nil && toIndex != nil) else { return }
66 | reorder(from: [fromIndex!], to: toIndex!)
67 | }
68 |
69 | /// Reorders the boards.
70 | func reorder(from set: IndexSet, to destinationIndex: Int) {
71 | boards.move(fromOffsets: set, toOffset: destinationIndex)
72 | saveBoards()
73 | }
74 |
75 | // ---------------------------------------------------------------- finder
76 |
77 | func findTaskById(_ taskId: TaskId) -> (Board, TaskList, TaskGroup, Task)? {
78 | var result: (Board, TaskList, TaskGroup, Task)? = nil
79 |
80 | boards.forEach { board in
81 | let lists = services.lists.fetchBoardLists(board.id)
82 | lists.forEach { list in
83 | list.groups.forEach { group in
84 | group.tasks.forEach { task in
85 | if (task.id == taskId) {
86 | result = (board, list, group, task)
87 | return
88 | }
89 | }
90 | if (result != nil) {
91 | return
92 | }
93 | }
94 | if (result != nil) {
95 | return
96 | }
97 | }
98 | }
99 | return result
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/Clmn/ViewModels/AllTaskListsViewModel.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | typealias TaskListProvider = ()->TaskList
4 |
5 | class AllTaskListsVM: ObservableObject {
6 | @Published private(set) var lists: [TaskList] = []
7 |
8 | private var board: Board = Board.foo
9 |
10 | func loadLists(board: Board) {
11 | lists = services.lists.fetchBoardLists(board.id)
12 | self.board = board
13 | queue.clear()
14 | }
15 |
16 | func saveLists() {
17 | services.lists.storeBoardLists(boardId: board.id, boardTaskList: lists)
18 | }
19 |
20 | @discardableResult
21 | func addNewList(_ title: String, description: String? = nil) -> TaskList {
22 | let list = TaskList(
23 | boardId: board.id,
24 | title: title.trim(),
25 | description: String.trimAndNil(description)
26 | )
27 | lists.append(list)
28 | saveLists()
29 | return list
30 | }
31 |
32 | func deleteList(_ list: TaskList) {
33 | lists.removeElement(list)
34 | saveLists()
35 | }
36 |
37 | func updateList(_ list: TaskList, _ title: String, _ description: String?) {
38 | lists.with(list) { index in
39 | lists[index].title = title.trim()
40 | lists[index].description = String.trimAndNil(description)
41 | }
42 | saveLists()
43 | }
44 |
45 | func addOrUpdateList(list: TaskList?, _ title: String, _ description: String?) {
46 | if (list == nil) {
47 | addNewList(title, description: description)
48 | } else {
49 | updateList(list!, title, description)
50 | }
51 | }
52 |
53 | // ---------------------------------------------------------------- reorder
54 |
55 | func reorder(from: TaskList, to destination: TaskList) {
56 | let fromIndex = lists.firstIndex(of: from)
57 | let toIndex = lists.firstIndex(of: destination)
58 | guard (fromIndex != nil && toIndex != nil) else { return }
59 | reorder(from: [fromIndex!], to: toIndex!)
60 | }
61 |
62 | /// Reorders the lists.
63 | func reorder(from set: IndexSet, to destinationIndex: Int) {
64 | lists.move(fromOffsets: set, toOffset: destinationIndex)
65 | saveLists()
66 | }
67 |
68 | // ---------------------------------------------------------------- child VMs
69 |
70 | private var queue: Queue = Queue()
71 |
72 | /// Registers tasklist provider that will queued and used later.
73 | func register(_ taskListProvider: @escaping TaskListProvider) {
74 | queue.push(taskListProvider)
75 | }
76 |
77 | /// Handle queued list changes. If there are changes, invoke the commit
78 | /// lambda function.
79 | func handleListChanges(commit: ()->Void) {
80 | if (queue.size == 0) {
81 | return
82 | }
83 | while true {
84 | let tlp = queue.pop()
85 | if tlp == nil {
86 | break
87 | }
88 | let listToReplace = tlp!()
89 | apply(from: listToReplace)
90 | }
91 | commit()
92 | }
93 |
94 | func apply(from list: TaskList) {
95 | let ndx = lists.firstIndex(where: {tl in tl.id == list.id})
96 | guard (ndx != nil) else { return }
97 | lists[ndx!] = list
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/Clmn/Views/Main/SplitView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | public struct AppSplitView: View {
4 | private let primaryMinWidth: CGFloat = 150
5 | @AppStorage("sidebar.hidden") private var primaryHidden: Bool = false
6 | @AppStorage("sidebar.width") private var primaryWidth: Double = 150
7 |
8 | let primary: P
9 | let secondary: S
10 |
11 | public var body: some View {
12 | HStack(spacing: 0) {
13 | if (!primaryHidden) {
14 | primary
15 | .frame(width: primaryWidth)
16 | }
17 |
18 | SplitDivider(minDimension: primaryMinWidth, dimension: $primaryWidth)
19 | .onTapGesture(count: 2) {
20 | primaryHidden.toggle()
21 | }
22 |
23 | secondary
24 | .frame(maxWidth: .infinity)
25 | }
26 | }
27 |
28 | init(sidebar primary: P, main secondary: S) {
29 | self.primary = primary
30 | self.secondary = secondary
31 | }
32 | }
33 |
34 | fileprivate struct SplitDivider: View {
35 | private var minDimension: Double
36 | @Binding var dimension: Double
37 | @State private var dimensionStart: Double?
38 | @State private var dragStarted = true
39 |
40 | public init(minDimension: Double, dimension: Binding) {
41 | self.minDimension = minDimension
42 | self._dimension = dimension
43 | }
44 |
45 | public var body: some View {
46 | // Color.red
47 | SliderBackgroundViewImpl()
48 | .frame(width: 10)
49 | .gesture(drag)
50 |
51 | }
52 |
53 | var drag: some Gesture {
54 | DragGesture(minimumDistance: 10, coordinateSpace: CoordinateSpace.global)
55 | .onChanged { drag in
56 | if (dragStarted) {
57 | NSCursor.resizeLeftRight.push()
58 | dragStarted = false
59 | }
60 | if dimensionStart == nil {
61 | dimensionStart = dimension
62 | }
63 | let delta = drag.location.x - drag.startLocation.x
64 | dimension = dimensionStart! + delta
65 | if (dimension < minDimension) {
66 | dimension = minDimension
67 | }
68 | }
69 | .onEnded { val in
70 | dimensionStart = nil
71 | NSCursor.pop()
72 | dragStarted = true
73 | }
74 | }
75 | }
76 |
77 | struct SliderBackgroundViewImpl: NSViewRepresentable {
78 | func makeNSView(context: NSViewRepresentableContext) -> SliderBackgroundView {
79 | SliderBackgroundView(frame: NSRect(x: 0, y: 0, width: 100, height: 200))
80 | }
81 | func updateNSView(_ nsView: SliderBackgroundView, context: NSViewRepresentableContext) {
82 | }
83 | }
84 | class SliderBackgroundView: NSView {
85 | override func draw(_ rect: CGRect) {
86 | guard let context = NSGraphicsContext.current?.cgContext else { return }
87 |
88 | let T: CGFloat = 15 // desired thickness of lines
89 | let G: CGFloat = 15 // desired gap between lines
90 | let W = rect.size.width
91 | let H = rect.size.height
92 |
93 | context.setStrokeColor(NSColor(Color.App.listSplitLine).cgColor)
94 | context.setLineWidth(T)
95 |
96 | var p = -(W > H ? W : H) - T
97 | while p <= W {
98 | context.move(to: CGPoint(x: p - T, y: -T))
99 | context.addLine(to: CGPoint(x: p + T + H, y: T + H))
100 | context.strokePath()
101 | p += G + T + T
102 | }
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/Clmn/Views/TaskList/TaskListTitle.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct TaskListTitle: View {
4 | var list: TaskList
5 | var allListsVM: AllTaskListsVM
6 | var listVM: TaskListVM
7 |
8 | @Binding var taskListDetails: ModelOpt?
9 | @Binding var taskGroupDetails: ModelOpt?
10 | @Binding var taskDetails: ModelPairOpt?
11 | @Binding var deleteTaskList: DeleteIntent
12 | @Binding var hovered: Bool
13 | var isLast: Bool
14 |
15 | var body: some View {
16 | VStack(alignment: .leading, spacing: 0) {
17 | TaskListButton(
18 | list: list,
19 | taskListDetails: $taskListDetails,
20 | hovered: $hovered,
21 | isLast: isLast
22 | )
23 | VStack(alignment: .leading, spacing: 2) {
24 | HStack(alignment: .top) {
25 | Text(list.title)
26 | .font(Font.App.listTitle)
27 | Spacer()
28 | }
29 | .padding(.top, 10)
30 | Text((list.description ?? "").markdown())
31 | .font(Font.App.listSubtitle)
32 | .foregroundColor(Color.App.listSubtitle)
33 | }
34 | .padding(.horizontal)
35 | }
36 | .contentShape(Rectangle())
37 | .padding(.bottom, 4)
38 | .contextMenu {
39 | Button {
40 | taskListDetails = ModelOpt.of(list)
41 | } label: {
42 | Label("Edit List", systemImage: Icons.edit)
43 | .labelStyle(.titleAndIcon)
44 | }
45 | Button(role: .destructive) {
46 | deleteTaskList.set(list)
47 | } label: {
48 | Label("Delete List", systemImage: Icons.delete)
49 | .labelStyle(.titleAndIcon)
50 | }
51 | Divider()
52 | Button {
53 | taskListDetails = ModelOpt.ofNew()
54 | } label: {
55 | Label("Add List", systemImage: Icons.addList)
56 | .labelStyle(.titleAndIcon)
57 | }
58 | Divider()
59 | Menu {
60 | Button {
61 | // TODO Improve this!
62 | listVM.deleteCanceledTasks()
63 | allListsVM.apply(from: listVM.list)
64 | } label: {
65 | Label("Delete canceled tasks", systemImage: Icons.cancelTask)
66 | .labelStyle(.titleAndIcon)
67 | }
68 | Button {
69 | // TODO Improve this!
70 | listVM.deleteCompletedTasks()
71 | allListsVM.apply(from: listVM.list)
72 | } label: {
73 | Label("Delete completed tasks", systemImage: Icons.completeTask)
74 | .labelStyle(.titleAndIcon)
75 | }
76 | } label: {
77 | Label("List actions...", systemImage: Icons.taskActions)
78 | .labelStyle(.titleAndIcon)
79 | }
80 | Button {
81 | taskGroupDetails = ModelOpt.ofNew()
82 | } label: {
83 | Label("Add Group", systemImage: Icons.addGroup)
84 | .labelStyle(.titleAndIcon)
85 | }
86 | Button {
87 | taskDetails = ModelPairOpt.ofNew(list.defaultGroup())
88 | } label: {
89 | Label("Add Task", systemImage: Icons.addTask)
90 | .labelStyle(.titleAndIcon)
91 | }
92 | }
93 | }
94 |
95 | }
96 |
--------------------------------------------------------------------------------
/Clmn/Views/Main/MainView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct MainView: View {
4 | @StateObject var allBoardsVM = AllBoardsVM()
5 |
6 | @State private var selectedBoard: Board?
7 | @State private var selectedTask: Task?
8 | @State private var taskListDetails: ModelOpt?
9 |
10 | @State private var boardDetails: ModelOpt?
11 | @State private var deleteBoard: DeleteIntent = DeleteIntent()
12 |
13 | @EnvironmentObject var addExample: AddExampleModel
14 | @EnvironmentObject var dragBoard: DragBoardModel
15 |
16 | var body: some View {
17 | #if DEBUG
18 | let _ = Self._printChanges()
19 | #endif
20 |
21 | AppSplitView(
22 | sidebar: boardsListView,
23 | main: selectedBoardView
24 | )
25 | .sheet(item: $boardDetails) { item in
26 | BoardSheet(board: item.model, allBoardsVM: allBoardsVM, selectedBoard: $selectedBoard)
27 | }
28 | .deleteBoardConfirmation($deleteBoard) { deletedBoard in
29 | let deletedIndex = allBoardsVM.deleteBoard(deletedBoard)
30 | selectedBoard = allBoardsVM.boards.safeGet(deletedIndex)
31 | allBoardsVM.objectWillChange.send()
32 | }
33 | .onAppear {
34 | allBoardsVM.loadBoards()
35 | selectedBoard = allBoardsVM.boards.first
36 | }
37 | .onReceive([addExample.state].publisher.first()) { value in
38 | if (value > 0) {
39 | createExample(with: allBoardsVM)
40 | selectedBoard = allBoardsVM.boards.last
41 | addExample.reset()
42 | }
43 | }
44 | .onOpenURL(perform: { url in
45 | if (url.host == "tasks" && url.pathComponents.count >= 2) {
46 | let taskId = url.pathComponents[1]
47 | guard let locatedTask = allBoardsVM.findTaskById(TaskId(uuidString: taskId)!) else {
48 | return
49 | }
50 | selectedTask = locatedTask.3
51 | selectedBoard = locatedTask.0
52 | }
53 | })
54 | }
55 |
56 | /// BOARDS
57 | private var boardsListView: some View {
58 | VStack(spacing: 0) {
59 | Spacer().frame(height: 10) // need this to prevent coloring issue.
60 | ForEach(allBoardsVM.boards, id: \.id) { board in
61 | Button {
62 | selectedBoard = board
63 | } label: {
64 | Text(board.name)
65 | .font(Font.App.sideboard)
66 | .padding(5)
67 | .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
68 | .contentShape(Rectangle())
69 | .onDrag {
70 | dragBoard.startDragOf(board)
71 | }
72 | }
73 | .buttonStyle(.plain)
74 | .background(selectedBoard == board ? Color.App.sidebarSelected : .clear)
75 | .contextMenu {
76 | Button {
77 | boardDetails = ModelOpt.of(board)
78 | } label: {
79 | Label("Edit Board", systemImage: Icons.edit)
80 | .labelStyle(.titleAndIcon)
81 | }
82 | Button(role: .destructive) {
83 | deleteBoard.set(board)
84 | } label: {
85 | Label("Delete Board", systemImage: Icons.delete)
86 | .labelStyle(.titleAndIcon)
87 | }
88 | Divider()
89 | Button {
90 | taskListDetails = ModelOpt.ofNew()
91 | } label: {
92 | Label("New List", systemImage: Icons.addList)
93 | .labelStyle(.titleAndIcon)
94 | }
95 | }
96 | .onDrop(
97 | of: [BOARD_UTI],
98 | delegate: DragBoardDropOnBoard(
99 | source: dragBoard,
100 | target: board,
101 | reorder: { s, d in allBoardsVM.reorder(from: s, to: d) }
102 | )
103 | )
104 | }
105 | Spacer()
106 | Divider()
107 | HStack {
108 | Button(
109 | action: { boardDetails = ModelOpt.ofNew() },
110 | label: {
111 | Image(systemName: Icons.addBoard)
112 | Text("Add Board")
113 | }
114 | )
115 | .controlSize(.large)
116 | }
117 | .padding()
118 | }
119 | .background(Color.App.sidebarBackground)
120 | .colorScheme(.dark)
121 | }
122 |
123 | /// SINGLE SELECTED BOARD
124 | private var selectedBoardView: some View {
125 | VStack(spacing: 0) {
126 | if (allBoardsVM.isEmpty()) {
127 | ZeroBoardView()
128 | }
129 | ForEach(allBoardsVM.boards, id: \.id) { board in
130 | if (selectedBoard == board) {
131 | BoardView(
132 | board: board,
133 | taskListDetails: $taskListDetails,
134 | selectedTask: $selectedTask)
135 | }
136 | }
137 | }
138 | .layoutPriority(1)
139 | }
140 | }
141 |
142 | fileprivate struct SizePreferenceKey: PreferenceKey {
143 | static var defaultValue: CGSize = .zero
144 |
145 | static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
146 | value = nextValue()
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/Clmn/ViewModels/TaskListViewModel.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import CoreData
3 |
4 | class TaskListVM: ObservableObject {
5 | @Published private(set) var list: TaskList
6 |
7 | init(_ list: TaskList) {
8 | self.list = list
9 | }
10 |
11 | /// Adds a new task to the list or a group.
12 | func addNewTask(toGroup group: TaskGroup? = nil, _ name: String, note: String? = nil, color: Int = 0, progress: Int = 0, completed: Bool = false) {
13 | var task = Task(
14 | name: name.trim(),
15 | note: String.trimAndNil(note),
16 | color: color,
17 | completed: completed,
18 | progress: progress)
19 | if (completed) {
20 | task.completedAt = Date.now
21 | }
22 | let realGroup = group ?? list.groups[0]
23 | list.groups.with(realGroup) { g in
24 | list.groups[g].tasks.append(task)
25 | }
26 | }
27 |
28 | private func updateTask(_ task: Task, _ name: String, note: String? = nil, color: Int? = nil) {
29 | list.groups.with(task) { g, i in
30 | list.groups[g].tasks[i].name = name.trim()
31 | list.groups[g].tasks[i].note = String.trimAndNil(note)
32 | if (color != nil) {
33 | list.groups[g].tasks[i].color = color!
34 | }
35 | }
36 | }
37 |
38 | func addOrUpdateTask(group: TaskGroup, task: Task?, name: String, note: String?, color: Int) {
39 | if (task == nil) {
40 | addNewTask(toGroup: group, name, note: note, color: color)
41 | } else {
42 | updateTask(task!, name, note: note, color: color)
43 | }
44 | }
45 |
46 | /// Removes a task from the list.
47 | func deleteTask(_ task: Task) {
48 | list.groups.with(task) { g, i in
49 | list.groups[g].tasks.remove(at: i)
50 | }
51 | }
52 |
53 | /// Deletes all completed and canceled tasks.
54 | func deleteCompletedTasks() {
55 | for (i, _) in list.groups.enumerated() {
56 | list.groups[i].tasks.removeAll(where: { t in t.completed } )
57 | }
58 | }
59 | func deleteCanceledTasks() {
60 | for (i, _) in list.groups.enumerated() {
61 | list.groups[i].tasks.removeAll(where: { t in t.canceled() } )
62 | }
63 | }
64 |
65 | func insertTask(_ task: Task, before: Task) {
66 | list.groups.with(before) { g, i in
67 | list.groups[g].tasks.insert(task, at: i)
68 | }
69 | }
70 |
71 | func addTask(toGroup group: TaskGroup, task: Task) {
72 | list.groups.with(group) { g in
73 | list.groups[g].tasks.append(task)
74 | }
75 | }
76 |
77 | func addTaskToList(_ task: Task) {
78 | list.groups[0].tasks.append(task)
79 | }
80 |
81 | // ---------------------------------------------------------------- mutators
82 |
83 | func toggleProgress(_ task: Task) {
84 | list.groups.with(task) { g, i in
85 | var progress = list.groups[g].tasks[i].progress
86 | progress += 1
87 | if (progress == 3) {
88 | progress = 0
89 | }
90 | list.groups[g].tasks[i].progress = progress
91 | }
92 | }
93 |
94 | func toggleCancel(_ task: Task) {
95 | list.groups.with(task) { g, i in
96 | var progress = list.groups[g].tasks[i].progress
97 | if (progress >= 0) {
98 | progress = -1
99 | }
100 | else {
101 | progress = 0
102 | }
103 | list.groups[g].tasks[i].progress = progress
104 | }
105 | }
106 |
107 | func toggleCompleted(_ task: Task) {
108 | list.groups.with(task) { g, i in
109 | var completed = list.groups[g].tasks[i].completed
110 | completed.toggle()
111 | list.groups[g].tasks[i].completedAt = completed ? Date.now : nil
112 | list.groups[g].tasks[i].progress = 0
113 | list.groups[g].tasks[i].completed = completed
114 | }
115 | }
116 |
117 | func reorder(group: TaskGroup, source: Task, destination: Task) {
118 | let fromIndex = group.tasks.firstIndex(of: source)
119 | let toIndex = group.tasks.firstIndex(of: destination)
120 | guard (fromIndex != nil && toIndex != nil) else { return }
121 | list.groups.with(group) { g in
122 | list.groups[g].tasks.move(fromOffsets: [fromIndex!], toOffset: toIndex!)
123 | }
124 | }
125 |
126 | // ---------------------------------------------------------------- groups
127 |
128 | @discardableResult
129 | func addNewTaskGroup(_ name: String) -> TaskGroup {
130 | let group = TaskGroup(
131 | name: name.trim()
132 | )
133 | list.groups.append(group)
134 | return group
135 | }
136 |
137 | func deleteTaskGroup(_ taskGroup: TaskGroup) {
138 | list.groups.removeElement(taskGroup)
139 | }
140 |
141 | private func updateTaskGroup(_ taskGroup: TaskGroup, _ name: String) {
142 | list.groups.with(taskGroup) { index in
143 | list.groups[index].name = name.trim()
144 | }
145 | }
146 |
147 | func addOrUpdateTaskGroup(group: TaskGroup?, _ name: String) {
148 | if (group == nil) {
149 | addNewTaskGroup(name)
150 | } else {
151 | updateTaskGroup(group!, name)
152 | }
153 | }
154 |
155 | func reorder(source: TaskGroup, destination: TaskGroup) {
156 | let fromIndex = list.groups.firstIndex(of: source)
157 | let toIndex = list.groups.firstIndex(of: destination)
158 | guard (fromIndex != nil && toIndex != nil) else { return }
159 | list.groups.move(fromOffsets: [fromIndex!], toOffset: toIndex!)
160 | }
161 |
162 | // ---------------------------------------------------------------- parent VM
163 |
164 | func load(from lists: [TaskList]) {
165 | lists.with(list) { index in
166 | list = lists[index]
167 | }
168 | }
169 | }
170 |
--------------------------------------------------------------------------------
/Clmn/Library/+Color.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import AppKit
3 |
4 | extension Color {
5 | // see: https://developer.apple.com/documentation/appkit/nscolor
6 |
7 | // MARK: Label Colors
8 | static let label = Color(NSColor.labelColor)
9 | static let secondaryLabel = Color(NSColor.secondaryLabelColor)
10 | static let tertiaryLabel = Color(NSColor.tertiaryLabelColor)
11 | static let quaternaryLabel = Color(NSColor.quaternaryLabelColor)
12 |
13 | // MARK: Text Colors
14 | static let text = Color(NSColor.textColor)
15 | static let placeholderText = Color(NSColor.placeholderTextColor)
16 | static let selectedText = Color(NSColor.selectedTextColor)
17 | static let textBackground = Color(NSColor.textBackgroundColor)
18 | static let selectedTextBackground = Color(NSColor.selectedTextBackgroundColor)
19 | static let keyboardFocusIndicator = Color(NSColor.keyboardFocusIndicatorColor)
20 | static let unemphasizedSelectedText = Color(NSColor.unemphasizedSelectedTextColor)
21 | static let unemphasizedSelectedTextBackground = Color(NSColor.unemphasizedSelectedTextBackgroundColor)
22 |
23 | // MARK: Content Colors
24 | static let link = Color(NSColor.linkColor)
25 | static let separator = Color(NSColor.separatorColor)
26 | static let selectedContentBackground = Color(NSColor.selectedContentBackgroundColor)
27 | static let unemphasizedSelectedContentBackground = Color(NSColor.unemphasizedSelectedContentBackgroundColor)
28 |
29 | // MARK: Menu Colors
30 | static let selectedMenuItemText = Color(NSColor.selectedMenuItemTextColor)
31 |
32 | // MARK: Table Colors
33 | static let grid = Color(NSColor.gridColor)
34 | static let headerText = Color(NSColor.headerTextColor)
35 | static let alternatingContentBackground0 = Color(NSColor.alternatingContentBackgroundColors[0])
36 | static let alternatingContentBackground1 = Color(NSColor.alternatingContentBackgroundColors[1])
37 |
38 | // MARK: Control Colors
39 | static let controlAccent = Color(NSColor.controlAccentColor)
40 | static let control = Color(NSColor.controlColor)
41 | static let controlBackground = Color(NSColor.controlBackgroundColor)
42 | static let controlText = Color(NSColor.controlTextColor)
43 | static let disabledControlText = Color(NSColor.disabledControlTextColor)
44 | static let selectedControl = Color(NSColor.selectedControlColor)
45 | static let selectedControlText = Color(NSColor.selectedControlTextColor)
46 | static let alternateSelectedControlText = Color(NSColor.alternateSelectedControlTextColor)
47 | static let scrubberTexturedBackground = Color(NSColor.scrubberTexturedBackground)
48 |
49 | // MARK: Window Colors
50 | static let windowBackground = Color(NSColor.windowBackgroundColor)
51 | static let windowFrameText = Color(NSColor.windowFrameTextColor)
52 | static let underPageBackground = Color(NSColor.underPageBackgroundColor)
53 |
54 | // MARK: Highlights and Shadows
55 | static let findHighlight = Color(NSColor.findHighlightColor)
56 | static let highlight = Color(NSColor.highlightColor)
57 | static let shadow = Color(NSColor.shadowColor)
58 |
59 | // MARK: System Colors
60 | static let systemBlue = Color(NSColor.systemBlue)
61 | static let systemBrown = Color(NSColor.systemBrown)
62 | static let systemGray = Color(NSColor.systemGray)
63 | static let systemGreen = Color(NSColor.systemGreen)
64 | static let systemIndigo = Color(NSColor.systemIndigo)
65 | static let systemOrange = Color(NSColor.systemOrange)
66 | static let systemPink = Color(NSColor.systemPink)
67 | static let systemPurple = Color(NSColor.systemPurple)
68 | static let systemRed = Color(NSColor.systemRed)
69 | static let systemTeal = Color(NSColor.systemTeal)
70 | static let systemYellow = Color(NSColor.systemYellow)
71 |
72 | }
73 |
74 | extension Color {
75 |
76 | /// Creates a color from a hex string.
77 | init?(hex: String) {
78 | var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
79 | hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")
80 |
81 | var rgb: UInt64 = 0
82 |
83 | var r: CGFloat = 0.0
84 | var g: CGFloat = 0.0
85 | var b: CGFloat = 0.0
86 | var a: CGFloat = 1.0
87 |
88 | let length = hexSanitized.count
89 |
90 | guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else {
91 | return nil
92 | }
93 |
94 | if length == 6 {
95 | r = CGFloat((rgb & 0xFF0000) >> 16) / 255.0
96 | g = CGFloat((rgb & 0x00FF00) >> 8) / 255.0
97 | b = CGFloat(rgb & 0x0000FF) / 255.0
98 |
99 | } else if length == 8 {
100 | r = CGFloat((rgb & 0xFF000000) >> 24) / 255.0
101 | g = CGFloat((rgb & 0x00FF0000) >> 16) / 255.0
102 | b = CGFloat((rgb & 0x0000FF00) >> 8) / 255.0
103 | a = CGFloat(rgb & 0x000000FF) / 255.0
104 |
105 | } else {
106 | return nil
107 | }
108 |
109 | self.init(red: r, green: g, blue: b, opacity: a)
110 | }
111 |
112 | //// Returns hex String value of the color.
113 | func toHex() -> String? {
114 | let nsc = NSColor(self)
115 | guard let components = nsc.cgColor.components, components.count >= 3 else {
116 | return nil
117 | }
118 | let r = Float(components[0])
119 | let g = Float(components[1])
120 | let b = Float(components[2])
121 | var a = Float(1.0)
122 |
123 | if components.count >= 4 {
124 | a = Float(components[3])
125 | }
126 |
127 | if a != Float(1.0) {
128 | return String(format: "%02lX%02lX%02lX%02lX", lroundf(r * 255), lroundf(g * 255), lroundf(b * 255), lroundf(a * 255))
129 | } else {
130 | return String(format: "%02lX%02lX%02lX", lroundf(r * 255), lroundf(g * 255), lroundf(b * 255))
131 | }
132 | }
133 |
134 | static var random: Color {
135 | Color(red: .random(in: 0...1),
136 | green: .random(in: 0...1),
137 | blue: .random(in: 0...1))
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/Clmn/Components/MacTextEditor.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// Proper text (field) editor, because default one really sucks.
4 | struct MacTextEditor: NSViewRepresentable {
5 |
6 | var placeholderText: String?
7 | var placeholderColor: Color = Color.gray
8 | @Binding var text: String
9 | var singleLine: Bool = false
10 | var moveCursorToEnd: Bool = true
11 | var font: NSFont = .systemFont(ofSize: 14, weight: .regular)
12 | var fontColor: Color = Color.text
13 |
14 | var onSubmit : () -> Void = {}
15 | var onTextChange : (String) -> Void = { _ in }
16 | var onEditingChanged: () -> Void = {}
17 |
18 | // Need to compute text attributes as lazy var seems to be mutable, meh.
19 | private var textAttributes: [NSAttributedString.Key : Any] {
20 | [
21 | NSAttributedString.Key.font: font,
22 | .foregroundColor: NSColor(fontColor)
23 | ]
24 | }
25 |
26 | func makeCoordinator() -> Coordinator {
27 | Coordinator(self)
28 | }
29 |
30 | func makeNSView(context: Context) -> NSScrollView {
31 | let scrollView = PlaceholderNSTextView.scrollableTextView()
32 |
33 | guard let textView = scrollView.documentView as? PlaceholderNSTextView else {
34 | return scrollView
35 | }
36 |
37 | textView.delegate = context.coordinator
38 | textView.isRichText = false
39 | textView.importsGraphics = false
40 | textView.isEditable = true
41 | textView.isSelectable = true
42 | textView.drawsBackground = false
43 | textView.font = font
44 | textView.allowsUndo = true
45 |
46 | textView.placeholderColor = NSColor(placeholderColor)
47 | textView.placeholderText = placeholderText
48 |
49 | /// IMPORTANT DETAIL
50 | // There is a bug in macOS when the first letter of the text is an emoji.
51 | // textView.string = text
52 | textView.textStorage?.setAttributedString(NSAttributedString(string:text, attributes: textAttributes))
53 |
54 | scrollView.hasVerticalScroller = false
55 | scrollView.hasHorizontalRuler = false
56 | scrollView.borderType = .noBorder
57 | //scrollView.translatesAutoresizingMaskIntoConstraints = false
58 |
59 | if (singleLine) {
60 | scrollView.hasHorizontalScroller = false
61 | textView.maxSize = NSMakeSize(CGFloat.greatestFiniteMagnitude, CGFloat.greatestFiniteMagnitude)
62 | textView.isHorizontallyResizable = true
63 | textView.textContainer?.widthTracksTextView = false
64 | textView.textContainer?.containerSize = NSMakeSize(CGFloat.greatestFiniteMagnitude, CGFloat.greatestFiniteMagnitude)
65 | }
66 |
67 | if (moveCursorToEnd) {
68 | let originalPointColor = textView.insertionPointColor
69 | textView.insertionPointColor = NSColor.clear
70 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
71 | textView.moveToEndOfDocument(nil)
72 | textView.insertionPointColor = originalPointColor
73 | }
74 | // textView.becomeFirstResponder()
75 | // let cursorPosition = text.utf16.count
76 | // let cursorRange = NSRange(location: cursorPosition, length: 0)
77 | // textView.selectedRange = cursorRange
78 | // textView.scrollRangeToVisible(cursorRange)
79 | }
80 |
81 | return scrollView
82 | }
83 |
84 | func updateNSView(_ view: NSScrollView, context: Context) {
85 | guard let textView = view.documentView as? NSTextView else {
86 | return
87 | }
88 |
89 | // the range is reset when updating the string of the textView
90 | // so this will set it back to where it was previously
91 | let currentRange = textView.selectedRange()
92 |
93 | /// IMPORTANT DETAIL
94 | // There is a bug in macOS.
95 | //textView.string = text
96 | textView.textStorage?.setAttributedString(NSAttributedString(string:text, attributes: textAttributes))
97 |
98 | // set the selected range to what is was before the string update
99 | textView.setSelectedRange(currentRange)
100 | }
101 |
102 | }
103 |
104 | extension MacTextEditor {
105 | class Coordinator: NSObject, NSTextViewDelegate {
106 |
107 | var parent: MacTextEditor
108 |
109 | init(_ parent: MacTextEditor) {
110 | self.parent = parent
111 | }
112 |
113 | func textDidBeginEditing(_ notification: Notification) {
114 | guard let textView = notification.object as? NSTextView else {
115 | return
116 | }
117 |
118 | parent.text = textView.string
119 | parent.onEditingChanged()
120 | }
121 |
122 | func textDidChange(_ notification: Notification) {
123 | guard let textView = notification.object as? NSTextView else {
124 | return
125 | }
126 |
127 | parent.onTextChange(textView.string)
128 | parent.text = textView.string
129 | }
130 |
131 | func textDidEndEditing(_ notification: Notification) {
132 | guard let textView = notification.object as? NSTextView else {
133 | return
134 | }
135 |
136 | parent.text = textView.string
137 | parent.onSubmit()
138 | }
139 |
140 | // handles commands
141 | func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
142 | if (commandSelector == #selector(NSResponder.insertNewline(_:))) {
143 | parent.onSubmit() // ENTER is pressed
144 | return true
145 | }
146 | if (commandSelector == #selector(NSResponder.insertTab(_:))) {
147 | textView.window?.selectNextKeyView(nil)
148 | return true
149 | }
150 | if (commandSelector == #selector(NSResponder.insertBacktab(_:))) {
151 | textView.window?.selectPreviousKeyView(nil)
152 | return true
153 | }
154 |
155 | // return true if the action was handled; otherwise false
156 | return false
157 | }
158 | }
159 | }
160 |
161 | // For setting a proper placeholder text on an NSTextView.
162 | fileprivate class PlaceholderNSTextView: NSTextView {
163 | @objc private var placeholderAttributedString: NSAttributedString?
164 | var placeholderColor: NSColor?
165 | var placeholderText: String? {
166 | didSet {
167 | var attributes = [NSAttributedString.Key: AnyObject]()
168 | attributes[.font] = font
169 | attributes[.foregroundColor] = placeholderColor
170 | placeholderAttributedString = NSAttributedString(string: placeholderText ?? "", attributes: attributes)
171 | }
172 | }
173 | }
174 |
--------------------------------------------------------------------------------
/Clmn/Views/Task/TaskView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct TaskView: View {
4 | @ObservedObject var listVM: TaskListVM
5 | var task: Task
6 | var group: TaskGroup
7 | @Binding var taskDetails: ModelPairOpt?
8 | @Binding var deleteTask: DeleteIntent
9 | @Binding var selectedTask: Task?
10 |
11 | @State private var hovered = false
12 | @AppStorage(SETTINGS_HOVER_EDIT) private var hoverEdit = true
13 |
14 | @Environment(\.colorScheme) var colorScheme
15 | @AppStorage(SETTINGS_TASK_CHECKBOX_IMAGE) private var taskCheckboxImage = false
16 | @AppStorage(SETTINGS_TASK_SELECTABLE) private var taskSelectable = true
17 |
18 | var body: some View {
19 | #if DEBUG
20 | let _ = Self._printChanges()
21 | #endif
22 | VStack {
23 | HStack(alignment: .top) {
24 | Image(systemName: checkboxName(task))
25 | .font(Font.App.taskIcon)
26 | .gesture(TapGesture().onEnded {
27 | let optionKeyPressed = NSEvent.modifierFlags.contains(.option)
28 | let commandKeyPressed = NSEvent.modifierFlags.contains(.command)
29 | if (commandKeyPressed) {
30 | listVM.toggleProgress(task)
31 | } else if (optionKeyPressed) {
32 | listVM.toggleCancel(task)
33 | }
34 | else {
35 | listVM.toggleCompleted(task)
36 | }
37 | })
38 | .padding(.top, 2)
39 | .onHover { isHovered in CursorUtil.changeCursorOnHover(isHovered, cursor: NSCursor.pointingHand) }
40 | .foregroundColor(task.inactive() ? Color.App.taskCompleted : Color.App.listText)
41 |
42 | VStack {
43 | HStack(alignment: .top) {
44 | Text((task.name).trimmingCharacters(in: .whitespacesAndNewlines).markdown())
45 | .font(Font.App.taskText)
46 | .strikethrough(task.canceled())
47 | .foregroundColor(task.inactive() ? Color.App.taskCompleted : Color.App.listText)
48 |
49 | if (task.note != nil) {
50 | Image(systemName: Icons.taskNote)
51 | .foregroundColor(Color.App.listOffText)
52 | .frame(width: 10, height: 10)
53 | .padding(.top, 4)
54 | }
55 | Spacer()
56 | if (selected() || hovered) {
57 | Button(
58 | action: { taskDetails = ModelPairOpt.of(group, task) },
59 | label: {
60 | Image(systemName: Icons.ellipsis)
61 | .frame(width: 18, height: 18)
62 | .contentShape(Rectangle())
63 | }
64 | )
65 | .buttonStyle(.plain)
66 | }
67 | }
68 | if (selected() && task.note != nil) {
69 | HStack {
70 | Text(task.note?.markdown() ?? "")
71 | .font(Font.App.taskNote)
72 | .foregroundColor(Color.App.taskNote)
73 | .padding(.bottom, 4)
74 | Spacer()
75 | }
76 | }
77 | }
78 | }
79 | .padding(6)
80 | .onHover { hovered in
81 | self.hovered = hoverEdit == true ? hovered : false
82 | }
83 | }
84 | .if (selected() && taskSelectable) { view in
85 | view.colorScheme(colorScheme == .dark ? .light : .dark).background(Color.App.listSelect)
86 | }
87 | .background(taskColor(task))
88 | .roundedCorners(4, corners: .allCorners)
89 | .contentShape(Rectangle())
90 | .gesture(TapGesture().onEnded {
91 | select()
92 | })
93 | .padding(.bottom, 2)
94 | .contextMenu {
95 | Button {
96 | taskDetails = ModelPairOpt.of(group, task)
97 | } label: {
98 | Label("Edit Task", systemImage: Icons.edit)
99 | .labelStyle(.titleAndIcon)
100 | }
101 | Button(role: .destructive) {
102 | deleteTask.set(task)
103 | } label: {
104 | Label("Delete Task", systemImage: Icons.delete)
105 | .labelStyle(.titleAndIcon)
106 | }
107 | Divider()
108 | Button {
109 | listVM.toggleCancel(task)
110 | } label: {
111 | Label((task.progress == -1) ? "Enable task" : "Cancel task", systemImage: Icons.cancelTask)
112 | .labelStyle(.titleAndIcon)
113 | }
114 | Button {
115 | listVM.toggleCompleted(task)
116 | } label: {
117 | Label((task.completed) ? "Reopen task" : "Complete task", systemImage: Icons.completeTask)
118 | .labelStyle(.titleAndIcon)
119 | }
120 | Divider()
121 | Button {
122 | let pasteboard = NSPasteboard.general
123 | pasteboard.declareTypes([.string], owner: nil)
124 | pasteboard.setString("\(APP_HOST)://tasks/\(task.id)", forType: .string)
125 | } label: {
126 | Label("Copy URL to clipboard", systemImage: Icons.taskLink)
127 | .labelStyle(.titleAndIcon)
128 | }
129 | }
130 | }
131 |
132 | private func checkboxName(_ task: Task) -> String {
133 | if (task.completed) {
134 | if (taskCheckboxImage) {
135 | return Icons.taskCompleted2
136 | } else {
137 | return Icons.taskCompleted
138 | }
139 | }
140 | switch task.progress {
141 | case -1: return Icons.taskCanceled
142 | case 1: return Icons.taskProgress1
143 | case 2: return Icons.taskProgress2
144 | default:
145 | return Icons.taskOpen
146 | }
147 | }
148 |
149 | private func taskColor(_ task: Task) -> Color {
150 | if (task.color == 0) {
151 | return Color.App.listBackground
152 | }
153 | return Color.App.taskColors[task.color]
154 | }
155 |
156 | /// Returns `true` if task is selected.
157 | private func selected() -> Bool {
158 | guard selectedTask != nil else { return false }
159 | return selectedTask!.id == task.id
160 | }
161 |
162 | private func select() {
163 | if (selectedTask == nil) {
164 | selectedTask = task
165 | return
166 | }
167 | if (selectedTask!.id == task.id) {
168 | selectedTask = nil
169 | return
170 | }
171 | selectedTask = task
172 | }
173 |
174 | }
175 |
--------------------------------------------------------------------------------
/Clmn/AppExample.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | class AddExampleModel: ObservableObject {
4 | @Published var state: Int = 0
5 |
6 | func toggle() {
7 | state += 1
8 | }
9 |
10 | func reset() {
11 | state = 0
12 | }
13 | }
14 |
15 | /// Creates the example.
16 | func createExample(with allBoardsVM: AllBoardsVM) {
17 | allBoardsVM.loadBoards()
18 |
19 | with(allBoardsVM.addNewBoard("👔 Clients")) { board in
20 | let allListsVM = AllTaskListsVM()
21 | allListsVM.loadLists(board: board)
22 |
23 | with(allListsVM.addNewList("🚕 TzarCars", description: "Cars reseller website")) { list in
24 | let listVM = TaskListVM(list)
25 |
26 | listVM.addNewTask("Read documentation")
27 | listVM.addNewTask("☎️ Schedule a call", note: "This is an important call! Call **Frank** and Iva to join. Prepare PPT for the next Q.")
28 | listVM.addNewTask("Fix _Felix_ **issue**!!!")
29 | listVM.addNewTask("Upgrade components", progress: 1)
30 | listVM.addNewTask("Clean up resources", color: 2)
31 | listVM.addNewTask("Stress test, stress!")
32 | listVM.addNewTask("Talk to **SEO** team about the keywords")
33 | listVM.addNewTask("Publish new API schema")
34 |
35 | allListsVM.apply(from: listVM.list)
36 | }
37 |
38 | with(allListsVM.addNewList("🏕 Camparoo", description: "Camping _kangaroos_, with **style**")) { list in
39 | let listVM = TaskListVM(list)
40 |
41 | listVM.addNewTask("Migrate to new server")
42 | listVM.addNewTask("Copy volumes", color:3)
43 | listVM.addNewTask("Add health-check endpoint")
44 | with(listVM.addNewTaskGroup("Database")) { group in
45 | listVM.addNewTask(toGroup: group, "1️⃣ Migrate tables")
46 | listVM.addNewTask(toGroup: group, "2️⃣ Migrate data", progress: 2)
47 | }
48 |
49 | allListsVM.apply(from: listVM.list)
50 | }
51 |
52 | with(allListsVM.addNewList("💃 ThirtySt. Dancing", description: "Dance, dance, _dance_!")) { list in
53 | let listVM = TaskListVM(list)
54 |
55 | listVM.addNewTask("📙 Design booklet")
56 | with(listVM.addNewTaskGroup("🧠 Brainstorming")) { group in
57 | listVM.addNewTask(toGroup: group, "Try retro view; looking for 80-ies retro vibe")
58 | listVM.addNewTask(toGroup: group, "Record new videos? Upload them to new YouTube channel")
59 | listVM.addNewTask(toGroup: group, "Apply the new font", color: 4)
60 | }
61 | with(listVM.addNewTaskGroup("👩💻 Dev team")) { group in
62 | listVM.addNewTask(toGroup: group, "Clean up code smells")
63 | listVM.addNewTask(toGroup: group, "Add **README.ME** to _all_ repos")
64 | listVM.addNewTask(toGroup: group, "Implement new Github hook")
65 | }
66 |
67 | allListsVM.apply(from: listVM.list)
68 | }
69 |
70 | allListsVM.saveLists()
71 | }
72 |
73 | with(allBoardsVM.addNewBoard("📘 Kanban")) { board in
74 | let allListsVM = AllTaskListsVM()
75 | allListsVM.loadLists(board: board)
76 |
77 | with(allListsVM.addNewList("✴️ To-Do")) { list in
78 | let listVM = TaskListVM(list)
79 |
80 | listVM.addNewTask("Fix scroll issues")
81 | listVM.addNewTask("Add /wires API endpoint")
82 |
83 | with(listVM.addNewTaskGroup("🧊 Backlog")) { group in
84 | listVM.addNewTask(toGroup: group, "Clean up code smells", completed: true)
85 | listVM.addNewTask(toGroup: group, "Add **README.ME** to _all_ repos")
86 | listVM.addNewTask(toGroup: group, "Implement new Github hook")
87 | }
88 |
89 | allListsVM.apply(from: listVM.list)
90 | }
91 |
92 | with(allListsVM.addNewList("🚀 In-Progress")) { list in
93 | let listVM = TaskListVM(list)
94 |
95 | listVM.addNewTask("Connectivity issue")
96 | listVM.addNewTask("Refactor module", progress: 2)
97 |
98 | allListsVM.apply(from: listVM.list)
99 | }
100 |
101 |
102 | with(allListsVM.addNewList("✅ Done")) { list in
103 | let listVM = TaskListVM(list)
104 |
105 | listVM.addNewTask("Rename package", completed: true)
106 | listVM.addNewTask("🚀 Release v1.2", completed: true)
107 | listVM.addNewTask("Fix _drag-n-drop_ issue!", completed: true)
108 |
109 | allListsVM.apply(from: listVM.list)
110 | }
111 |
112 | allListsVM.saveLists()
113 | }
114 |
115 | with(allBoardsVM.addNewBoard("❤️ My Life")) { board in
116 | let allListsVM = AllTaskListsVM()
117 | allListsVM.loadLists(board: board)
118 |
119 | with(allListsVM.addNewList("🏡 House", description: "Mi casa tu casa")) { list in
120 | let listVM = TaskListVM(list)
121 |
122 | listVM.addNewTask("Fix kitchen cupboard")
123 | listVM.addNewTask("🧽 Clean room")
124 | listVM.addNewTask("🗑 Empty trash", color: 5)
125 | listVM.addNewTask("Order the books")
126 |
127 | allListsVM.apply(from: listVM.list)
128 | }
129 |
130 | with(allListsVM.addNewList("🛒 Buy stuff")) { list in
131 | let listVM = TaskListVM(list)
132 |
133 | listVM.addNewTask("**New keyboard**", color: 3)
134 | listVM.addNewTask("New T-Shirt")
135 |
136 | with(listVM.addNewTaskGroup("🍅 Grocery list")) { group in
137 | listVM.addNewTask(toGroup: group, "Tomatoes", color: 2)
138 | listVM.addNewTask(toGroup: group, "Potato")
139 | listVM.addNewTask(toGroup: group, "Mozzarella")
140 | listVM.addNewTask(toGroup: group, "Wine", color: 1)
141 | listVM.addNewTask(toGroup: group, "Bread")
142 | }
143 |
144 | allListsVM.apply(from: listVM.list)
145 | }
146 |
147 | with(allListsVM.addNewList("👩💻 Website")) { list in
148 | let listVM = TaskListVM(list)
149 |
150 | listVM.addNewTask("Update blog")
151 | listVM.addNewTask("Change MailX to something else")
152 | listVM.addNewTask("Buy **new** domain name")
153 | listVM.addNewTask("Add page counter")
154 | listVM.addNewTask("Add translations, yey")
155 |
156 | allListsVM.apply(from: listVM.list)
157 | }
158 |
159 | with(allListsVM.addNewList("⚙️ Renovation")) { list in
160 | let listVM = TaskListVM(list)
161 |
162 | listVM.addNewTask("Dining table", progress: 1)
163 | listVM.addNewTask("Measure kitchen")
164 | listVM.addNewTask("Call John and give him measurements: 220x80, depth: 60, white oak")
165 | listVM.addNewTask("Wooden tray")
166 |
167 | allListsVM.apply(from: listVM.list)
168 | }
169 |
170 | allListsVM.saveLists()
171 | }
172 |
173 |
174 | // finally
175 | allBoardsVM.saveBoards()
176 | }
177 |
178 | private func with(_ taskList: T, _ action: (T) -> Void = {_ in }) {
179 | action(taskList)
180 | }
181 |
--------------------------------------------------------------------------------
/Clmn/ClmnApp.swift:
--------------------------------------------------------------------------------
1 | import os
2 | import SwiftUI
3 |
4 | let APP_SITE = "https://clmnapp.com"
5 | let APP_EMAIL = "clmn@igo.rs"
6 | let APP_GROUP = "studio.oblac.clmn"
7 | let APP_NAME = "Clmn"
8 | let APP_HOST = "clmn"
9 |
10 | /// Meta-data
11 | let mainBundle = Bundle.main
12 | // application version, just a simple counter
13 | let APP_BUILD = Int(mainBundle.appBuild) ?? 0
14 | // application data version; the version of the models.
15 | let APP_DATA_VERSION = 1
16 |
17 | @main
18 | struct ClmnApp: App {
19 |
20 | private static let logger = Logger(
21 | subsystem: Bundle.main.bundleIdentifier!,
22 | category: String(describing: ClmnApp.self)
23 | )
24 |
25 | @AppStorage("appThemeSetting") private var appThemeSetting = Appearance.system
26 | @Environment(\.colorScheme) var colorScheme
27 |
28 | @AppStorage("sidebar.hidden") private var primaryHidden: Bool = false
29 |
30 | @StateObject var addExample = AddExampleModel()
31 | @StateObject var dragBoard: DragBoardModel = DragBoardModel()
32 | @StateObject var dragTask: DragTaskModel = DragTaskModel()
33 | @StateObject var dragTaskList: DragTaskListModel = DragTaskListModel()
34 | @StateObject var dragTaskGroup: DragTaskGroupModel = DragTaskGroupModel()
35 |
36 | @Environment(\.openWindow) private var openWindow
37 | @State var isMainWindowOpen = false
38 |
39 | init() {
40 | disallowTabbingMode()
41 | metaData()
42 | }
43 |
44 | var body: some Scene {
45 | WindowGroup(id: "MainWindow") {
46 | VStack(spacing: 0) {
47 | MainView()
48 | }
49 | .environmentObject(dragBoard)
50 | .environmentObject(dragTask)
51 | .environmentObject(dragTaskList)
52 | .environmentObject(dragTaskGroup)
53 | .environmentObject(addExample)
54 | .font(.system(.body, design: .default))
55 | .onAppear {
56 | Appearance.applyTheme(appThemeSetting)
57 | self.isMainWindowOpen = true
58 | }
59 | .onDisappear {
60 | self.isMainWindowOpen = false
61 | }
62 | .onChange(of: appThemeSetting) { newValue in
63 | Appearance.applyTheme(newValue)
64 | }
65 | // Handle minimize and show of the window
66 | .onReceive(NotificationCenter.default.publisher(for: NSApplication.didChangeOcclusionStateNotification)) { _ in
67 | if let window = NSApp.windows.first, window.isMiniaturized {
68 | NSWorkspace.shared.runningApplications.first(where: {
69 | $0.activationPolicy == .regular
70 | })?.activate(options: .activateAllWindows)
71 | }
72 | }
73 | .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
74 | if let window = NSApp.windows.first {
75 | window.deminiaturize(nil)
76 | }
77 | }
78 | // Change title bar color
79 | .onReceive(NotificationCenter.default.publisher(for: NSApplication.didFinishLaunchingNotification)) { _ in
80 | SideBarUtil.toggleSidebar()
81 | if let window = NSApp.windows.first {
82 | window.titlebarAppearsTransparent = true
83 | window.isMovableByWindowBackground = false
84 | window.titlebarSeparatorStyle = .none
85 | window.titleVisibility = .hidden
86 | window.backgroundColor = NSColor(Color.App.listBackground)
87 | // window.standardWindowButton(.closeButton)!.isHidden = true
88 | // window.standardWindowButton(.miniaturizeButton)!.isHidden = true
89 | // window.standardWindowButton(.zoomButton)!.isHidden = true
90 | }
91 | }
92 | }
93 | // already one inside notification
94 | .windowStyle(.hiddenTitleBar)
95 | .windowToolbarStyle(.unifiedCompact(showsTitle: false))
96 | .commands {
97 | MenuLine_File_NewWindow_Disable()
98 |
99 | // new menu option
100 | CommandGroup(before: .saveItem) {
101 | Button("Open Main Window") {
102 | if (!self.isMainWindowOpen) {
103 | self.openWindow(id: "MainWindow")
104 | }
105 | }.disabled(self.isMainWindowOpen)
106 | }
107 |
108 | MenuLine_Help_SupportEmail()
109 | MenuLine_View_ToggleBoards()
110 | MenuLine_View_Appearance()
111 | MenuLine_Help_Examples()
112 | MenuLine_About()
113 | }
114 | Settings {
115 | SettingsView()
116 | }
117 | }
118 |
119 | // ---------------------------------------------------------------- meta
120 |
121 | fileprivate func metaData() {
122 | let appMetaData = services.app.fetchMetadata()
123 | upgradeData(from: appMetaData.dataVersion, to: APP_DATA_VERSION)
124 |
125 | // at this point the application is updated
126 | services.app.storeMetadata(appVersion: APP_BUILD,
127 | dataVersion: APP_DATA_VERSION)
128 |
129 | Self.logger.info("\(APP_NAME): \(APP_BUILD)/\(APP_DATA_VERSION)")
130 | }
131 |
132 | // ---------------------------------------------------------------- menus
133 |
134 | /// Adds some menu button into Help menu.
135 | fileprivate func MenuLine_Help_SupportEmail() -> CommandGroup