├── .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 |
4 | 5 | clmnapp.com 6 |

7 | Thanx to: 8 |
Fridge by Vexy
9 |
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 | ![](clmn.png) 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 | ![](clmn1.png) 21 | 22 | ### Kanban board 23 | ![](clmn2.png) 24 | 25 | ### Just a bunch of tasks 26 | ![](clmn3.png) 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 | ![](mvvm.png) 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> { 136 | CommandGroup(after: CommandGroupPlacement.help) { 137 | Button("Support Email") { 138 | NSWorkspace.shared.open(URL(string: "mailto:\(APP_EMAIL)")!) 139 | } 140 | } 141 | } 142 | 143 | /// Disables "File -> New window" menu item (make it absent in release build). 144 | fileprivate func MenuLine_File_NewWindow_Disable() -> CommandGroup { 145 | CommandGroup(replacing: .newItem) { 146 | } 147 | } 148 | 149 | /// Adds some menu button into Help menu. 150 | fileprivate func MenuLine_Help_Examples() -> CommandGroup, Button)>> { 151 | CommandGroup(replacing: .help) { 152 | Button("Support Email") { 153 | NSWorkspace.shared.open(URL(string: "mailto:\(APP_EMAIL)")!) 154 | } 155 | Button("Add Example") { 156 | addExample.toggle() 157 | } 158 | } 159 | } 160 | 161 | /// Adds some items into the View menu. 162 | fileprivate func MenuLine_View_ToggleBoards() -> CommandGroup> { 163 | CommandGroup(before: CommandGroupPlacement.toolbar) { 164 | Button("Toggle Boards sidebar") { 165 | primaryHidden.toggle() 166 | } 167 | } 168 | } 169 | 170 | /// Adds Appearance in View menu. 171 | fileprivate func MenuLine_View_Appearance() -> CommandGroup>> { 172 | CommandGroup(before: CommandGroupPlacement.toolbar) { 173 | Picker("Appearance", selection: $appThemeSetting) { 174 | ForEach(Appearance.allCases) { appearance in 175 | Text(appearance.rawValue.capitalized) 176 | .tag(appearance) 177 | } 178 | } 179 | } 180 | } 181 | 182 | fileprivate func MenuLine_About() -> CommandGroup> { 183 | CommandGroup(replacing: .appInfo, addition: { 184 | Button(action: { 185 | AppAbout.aboutWindow().makeKeyAndOrderFront(nil) 186 | }, label: { 187 | let aboutSuffix = NSLocalizedString("About", comment: "") 188 | Text("\(aboutSuffix)\u{00a0}\(Bundle.main.appName)") 189 | }) 190 | }) 191 | } 192 | 193 | } 194 | 195 | fileprivate func disallowTabbingMode() { 196 | NSWindow.allowsAutomaticWindowTabbing = false 197 | } 198 | -------------------------------------------------------------------------------- /Clmn/Views/About/AppAbout.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Cocoa 3 | 4 | struct AppAbout { 5 | static let windowWidth: CGFloat = 388.0 6 | static let windowHeight: CGFloat = 268.0 7 | 8 | static func aboutWindow(for bundle: Bundle = Bundle.main) -> NSWindow { 9 | let origin = CGPoint.zero 10 | let size = CGSize(width: windowWidth, height: windowHeight) 11 | 12 | let window = NSWindow(contentRect: NSRect(origin: origin, size: size), 13 | styleMask: [.titled, .closable, .fullSizeContentView], 14 | backing: .buffered, 15 | defer: false) 16 | 17 | window.setFrameAutosaveName(bundle.appName) 18 | window.setAccessibilityTitle(bundle.appName) 19 | window.titlebarAppearsTransparent = true 20 | window.isMovableByWindowBackground = true 21 | window.isReleasedWhenClosed = false 22 | 23 | let aboutView = AppAboutView(bundle: bundle, 24 | appIconBackside: Image("AppBW"), 25 | creditsURL: "https://clmnapp.com") 26 | 27 | window.contentView = NSHostingView(rootView: aboutView) 28 | window.center() 29 | 30 | return window 31 | } 32 | } 33 | 34 | /// MARK: - About View 35 | fileprivate struct AppAboutView: View { 36 | let bundle: Bundle 37 | var appIconBackside: Image? = nil // 128pt × 128pt 38 | var creditsURL: String? = nil 39 | 40 | private let windowWidth: CGFloat = AppAbout.windowWidth 41 | private let windowHeight: CGFloat = AppAbout.windowHeight 42 | 43 | @State private var iconHover: Bool = false 44 | @State private var foregroundIconVisible: Bool = true 45 | @State private var backgroundIconVisible: Bool = false 46 | 47 | var body: some View { 48 | VStack(spacing: .zero) { 49 | HStack { 50 | 51 | appIcon 52 | 53 | VStack(spacing: .zero) { 54 | Spacer() 55 | 56 | appName 57 | 58 | Spacer() 59 | 60 | appLongText 61 | 62 | // Credits 63 | Group { 64 | if let creditsURLString = creditsURL { 65 | Button(action: { 66 | if let url = URL(string: creditsURLString) { 67 | NSWorkspace.shared.open(url) 68 | } 69 | }, label: { 70 | Text("Visit Website", bundle: bundle) 71 | .lineLimit(1) 72 | }) 73 | .buttonStyle(AppAboutWindowButtonStyle()) 74 | } 75 | } 76 | .padding([.top], 20.0) 77 | .padding([.bottom], 8.0) 78 | 79 | Spacer() 80 | Divider() 81 | .padding([.top], 16.0) 82 | } 83 | .frame(width: windowWidth, height: windowHeight) 84 | } 85 | 86 | bottomLine 87 | } 88 | } 89 | 90 | private var appLongText: some View { 91 | HStack { 92 | Text("Clmn is a beautiful task board native app for macOS. It is thoughtfully simple and unbearably efficient; nothing fancy, just enough. " + 93 | "Clmn is designed for professionals that work mostly on laptops.") 94 | } 95 | .padding() 96 | } 97 | 98 | private var appName: some View { 99 | VStack(spacing: .zero) { 100 | // App Name 101 | Text(bundle.appName) 102 | .font(Font.title.weight(.semibold)) 103 | .padding([.bottom], 6.0) 104 | 105 | // App Version & Build 106 | HStack(spacing: 4.0) { 107 | Text("Version\u{00a0}\(bundle.appVersion)") 108 | .font(Font.body.weight(.medium)) 109 | .foregroundColor(.secondary) 110 | 111 | Text("(\(bundle.appBuild))") 112 | .font(Font.body.monospacedDigit().weight(.regular)) 113 | .foregroundColor(.secondary) 114 | .opacity(0.7) 115 | } 116 | } 117 | } 118 | 119 | private var bottomLine: some View { 120 | HStack(spacing: .zero) { 121 | Spacer() 122 | Text("Coded with ❤️ and some 🌤️") 123 | Spacer() 124 | } 125 | .font(Font.footnote) 126 | .foregroundColor(.secondary) 127 | .opacity(0.7) 128 | .help("zzzz") 129 | .padding([.top], 12.0) 130 | .padding([.bottom], 14.0) 131 | .background(Color.primary.opacity(0.03)) 132 | } 133 | 134 | private var appIcon: some View { 135 | ZStack { 136 | // App Icon: Back 137 | Group { 138 | if let backside = appIconBackside { 139 | backside.resizable() 140 | } else { 141 | AppIconPlaceholder() 142 | } 143 | } 144 | .rotation3DEffect(backgroundIconVisible ? Angle.zero : Angle(degrees: -90.0), 145 | axis: (x: 0.0, y: 1.0, z: 0.0), 146 | anchor: .center, 147 | anchorZ: 0.0, 148 | perspective: -0.5) 149 | 150 | // App Icon: Front 151 | Group { 152 | if let appIcon = NSApp.applicationIconImage { 153 | Image(nsImage: appIcon) 154 | } else { 155 | AppIconPlaceholder() 156 | } 157 | } 158 | .rotation3DEffect(foregroundIconVisible ? Angle.zero : Angle(degrees: 90.0), 159 | axis: (x: 0.0, y: 1.0, z: 0.0), 160 | anchor: .center, 161 | anchorZ: 0.0, 162 | perspective: -0.5) 163 | 164 | } 165 | .frame(width: 128.0, height: 128.0) 166 | .brightness(iconHover ? 0.05 : 0.0) 167 | .padding([.bottom], 14.0) 168 | .padding([.trailing], 14.0) 169 | .onHover(perform: { state in 170 | let ani = Animation.easeInOut(duration: 0.16) 171 | withAnimation(ani, { 172 | self.iconHover = state 173 | }) 174 | 175 | if !state && backgroundIconVisible { 176 | flipIcon() 177 | } 178 | }) 179 | .onTapGesture(perform: { 180 | flipIcon() 181 | }) 182 | } 183 | 184 | private func flipIcon() { 185 | let reversed = foregroundIconVisible 186 | let inDuration = 0.12 187 | let inAnimation = Animation.easeIn(duration: inDuration) 188 | let outAnimation = Animation.easeOut(duration: 0.32) 189 | 190 | withAnimation(inAnimation, { 191 | if reversed { 192 | self.foregroundIconVisible.toggle() 193 | } else { 194 | self.backgroundIconVisible.toggle() 195 | } 196 | }) 197 | 198 | DispatchQueue.main.asyncAfter(deadline: .now() + inDuration) { 199 | withAnimation(outAnimation, { 200 | if !reversed { 201 | self.foregroundIconVisible.toggle() 202 | } else { 203 | self.backgroundIconVisible.toggle() 204 | } 205 | }) 206 | } 207 | } 208 | } 209 | 210 | fileprivate struct AppIconPlaceholder: View { 211 | private let cornerSize: CGSize = CGSize(width: 24.0, height: 24.0) 212 | var body: some View { 213 | RoundedRectangle(cornerSize: cornerSize, style: .continuous) 214 | .foregroundColor(Color.secondary) 215 | .padding(13.0) 216 | } 217 | } 218 | 219 | fileprivate struct AppAboutWindowButtonStyle: ButtonStyle { 220 | func makeBody(configuration: Configuration) -> some View { 221 | let color = Color.accentColor 222 | let pressed = configuration.isPressed 223 | return configuration.label 224 | .font(Font.body.weight(.medium)) 225 | .padding([.leading, .trailing], 8.0) 226 | .padding([.top], 4.0) 227 | .padding([.bottom], 5.0) 228 | .background(color.opacity(pressed ? 0.08 : 0.14)) 229 | .foregroundColor(color.opacity(pressed ? 0.8 : 1.0)) 230 | .cornerRadius(5.0) 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /Clmn/Views/TaskList/TaskListView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct TaskListView: View { 4 | @ObservedObject var allListsVM: AllTaskListsVM 5 | @StateObject var listVM: TaskListVM 6 | @Binding var selectedTask: Task? 7 | 8 | @State private var taskDetails: ModelPairOpt? 9 | @State private var taskListDetails: ModelOpt? 10 | @State private var taskGroupDetails: ModelOpt? 11 | 12 | @State private var hovered: Bool = false 13 | @AppStorage(SETTINGS_HOVER_EDIT) private var hoverEdit = true 14 | 15 | @State private var deleteTask: DeleteIntent = DeleteIntent() 16 | @State private var deleteTaskGroup: DeleteIntent = DeleteIntent() 17 | @State private var deleteTaskList: DeleteIntent = DeleteIntent() 18 | 19 | @EnvironmentObject var dragTask: DragTaskModel 20 | @EnvironmentObject var dragTaskList: DragTaskListModel 21 | @EnvironmentObject var dragTaskGroup: DragTaskGroupModel 22 | 23 | var body: some View { 24 | #if DEBUG 25 | let _ = Self._printChanges() 26 | #endif 27 | let list = listVM.list 28 | VStack(alignment: .leading, spacing: 0) { 29 | TaskListTitle( 30 | list: list, 31 | allListsVM: allListsVM, 32 | listVM: listVM, 33 | taskListDetails: $taskListDetails, 34 | taskGroupDetails: $taskGroupDetails, 35 | taskDetails: $taskDetails, 36 | deleteTaskList: $deleteTaskList, 37 | hovered: $hovered, 38 | isLast: isLast() 39 | ) 40 | .onDrag { 41 | dragTaskList.startDragOf(list) 42 | } 43 | ScrollView(showsIndicators: false) { 44 | LazyVStack(alignment: .leading, spacing: 0) { 45 | 46 | let defaultGroup = list.defaultGroup() 47 | let tasks = defaultGroup.tasks 48 | 49 | ForEach(tasks, id: \.id) { task in 50 | TaskView( 51 | listVM: listVM, 52 | task: task, 53 | group: defaultGroup, 54 | taskDetails: $taskDetails, 55 | deleteTask: $deleteTask, 56 | selectedTask: $selectedTask 57 | ) 58 | .onDrag { 59 | dragTask.startDragOf((list, defaultGroup, task), removeOnDrop: { task in listVM.deleteTask(task)}) 60 | } 61 | .onDrop( 62 | of: [TASK_UTI], 63 | delegate: DragTaskDropOnTask( 64 | source: dragTask, 65 | target: (defaultGroup, task), 66 | appendToTarget: { t in listVM.insertTask(t, before: task) }, 67 | reorder: { s, d in listVM.reorder(group: defaultGroup, source: s, destination: d)} 68 | ) 69 | ) 70 | } 71 | 72 | AddTaskButtons( 73 | hovered: $hovered, 74 | action: { taskDetails = ModelPairOpt.ofNew(defaultGroup) }, 75 | showAction2: list.groups.endIndex == 1, 76 | action2: { taskGroupDetails = ModelOpt.ofNew() } 77 | ) 78 | 79 | // ---------------------------------------------------------------- groups 80 | 81 | if (!defaultGroup.tasks.isEmpty) { 82 | Spacer().frame(height: 50) 83 | } 84 | 85 | let groups = list.appGroups() 86 | 87 | ForEach(groups, id: \.id) { group in 88 | TaskGroupView( 89 | group: group, 90 | taskGroupDetails: $taskGroupDetails, 91 | deleteTaskGroup: $deleteTaskGroup, 92 | hovered: $hovered 93 | ) 94 | .onDrag { 95 | dragTaskGroup.startDragOf(list, group) 96 | } 97 | .onDrop(of: [TASK_UTI, TASKGROUP_UTI], 98 | delegate: DropOnTaskGroupDispatcher( 99 | sourceTask: dragTask, 100 | sourceGroup: dragTaskGroup, 101 | target: (list, group), 102 | reorderGroups: listVM.reorder, 103 | appendTaskToGroup: { taskGroup, task in listVM.addTask(toGroup: taskGroup, task: task) } 104 | ) 105 | ) 106 | 107 | let tasks = group.tasks 108 | 109 | ForEach(tasks, id: \.id) { task in 110 | TaskView( 111 | listVM: listVM, 112 | task: task, 113 | group: group, 114 | taskDetails: $taskDetails, 115 | deleteTask: $deleteTask, 116 | selectedTask: $selectedTask 117 | ) 118 | .onDrag { 119 | dragTask.startDragOf((list, group, task), removeOnDrop: { task in listVM.deleteTask(task)}) 120 | } 121 | .onDrop( 122 | of: [TASK_UTI], 123 | delegate: DragTaskDropOnTask( 124 | source: dragTask, 125 | target: (group, task), 126 | appendToTarget: { t in listVM.insertTask(t, before: task) }, 127 | reorder: { s, d in listVM.reorder(group: group, source: s, destination: d)} 128 | ) 129 | ) 130 | } 131 | 132 | AddTaskButtons( 133 | hovered: $hovered, 134 | action: { taskDetails = ModelPairOpt.ofNew(group) }, 135 | showAction2: groups.isLast(group), 136 | action2: { taskGroupDetails = ModelOpt.ofNew() } 137 | ) 138 | if (!group.tasks.isEmpty) { 139 | Spacer().frame(height: 50) 140 | } 141 | } 142 | } 143 | } 144 | .padding() 145 | } 146 | .sheet(item: $taskListDetails) { item in 147 | TaskListSheet(list: item.model, allListsVM: allListsVM, listVM: listVM) 148 | } 149 | .sheet(item: $taskDetails) { item in 150 | TaskSheet(task: item.model, group: item.owner, listVM: listVM) 151 | } 152 | .sheet(item: $taskGroupDetails) { item in 153 | TaskGroupSheet(group: item.model, listVM: listVM) 154 | } 155 | .deleteTaskConfirmation($deleteTask) { deletedTask in listVM.deleteTask(deletedTask) } 156 | .deleteTaskGroupConfirmation($deleteTaskGroup) { deletedTaskGroup in listVM.deleteTaskGroup(deletedTaskGroup) } 157 | .deleteTaskListConfirmation($deleteTaskList) { deletedTaskList in allListsVM.deleteList(deletedTaskList) } 158 | .background(Color.App.listBackground) 159 | .roundedCorners(2, corners: .allCorners) 160 | .onDrop(of: [TASK_UTI, TASKLIST_UTI], 161 | delegate: DropOnTaskListDispatcher( 162 | sourceTask: dragTask, 163 | sourceList: dragTaskList, 164 | target: list, 165 | reorderLists: allListsVM.reorder, 166 | appendTaskToList: { t in listVM.addTaskToList(t) } 167 | ) 168 | ) 169 | .onHover { hovered in 170 | self.hovered = hoverEdit == true ? hovered : false 171 | } 172 | .onAppear { 173 | /// IMPORTANT DETAIL 174 | // Since this view disappear _after_ the parent view, we must 175 | // register tasklist providers now, so they become available 176 | // when parent disappear. 177 | allListsVM.register({ listVM.list }) 178 | // other initializations 179 | 180 | } 181 | } 182 | 183 | private func isLast() -> Bool { 184 | allListsVM.lists.isLast(listVM.list) 185 | } 186 | } 187 | --------------------------------------------------------------------------------