Skip to content

Commit cc2bc5d

Browse files
committed
Many improvements
1 parent c240934 commit cc2bc5d

16 files changed

Lines changed: 336 additions & 118 deletions

Impulso.xcodeproj/project.pbxproj

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@
246246
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
247247
CODE_SIGN_STYLE = Automatic;
248248
COMBINE_HIDPI_IMAGES = YES;
249-
CURRENT_PROJECT_VERSION = 1;
249+
CURRENT_PROJECT_VERSION = 13;
250250
DEVELOPMENT_ASSET_PATHS = "\"Impulso/Preview Content\"";
251251
DEVELOPMENT_TEAM = YYMLDY74QZ;
252252
ENABLE_HARDENED_RUNTIME = YES;
@@ -259,7 +259,7 @@
259259
"@executable_path/../Frameworks",
260260
);
261261
MACOSX_DEPLOYMENT_TARGET = 14.5;
262-
MARKETING_VERSION = 1.0;
262+
MARKETING_VERSION = 1.1;
263263
PRODUCT_BUNDLE_IDENTIFIER = Minimal.Impulso;
264264
PRODUCT_NAME = "$(TARGET_NAME)";
265265
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -276,7 +276,7 @@
276276
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
277277
CODE_SIGN_STYLE = Automatic;
278278
COMBINE_HIDPI_IMAGES = YES;
279-
CURRENT_PROJECT_VERSION = 1;
279+
CURRENT_PROJECT_VERSION = 13;
280280
DEVELOPMENT_ASSET_PATHS = "\"Impulso/Preview Content\"";
281281
DEVELOPMENT_TEAM = YYMLDY74QZ;
282282
ENABLE_HARDENED_RUNTIME = YES;
@@ -289,7 +289,7 @@
289289
"@executable_path/../Frameworks",
290290
);
291291
MACOSX_DEPLOYMENT_TARGET = 14.5;
292-
MARKETING_VERSION = 1.0;
292+
MARKETING_VERSION = 1.1;
293293
PRODUCT_BUNDLE_IDENTIFIER = Minimal.Impulso;
294294
PRODUCT_NAME = "$(TARGET_NAME)";
295295
SWIFT_EMIT_LOC_STRINGS = YES;

Impulso/Extensions/DragAndDropExtensions.swift

Lines changed: 70 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -95,13 +95,15 @@ private struct TaskDropDelegate: DropDelegate {
9595
return false
9696
}
9797

98-
var updatedTasks = tasks
99-
updatedTasks.remove(at: fromIndex)
100-
updatedTasks.insert(draggedTask, at: toIndex)
101-
102-
onReorder(updatedTasks)
103-
self.draggedTask = nil
104-
self.targetIndex = nil
98+
DispatchQueue.main.async {
99+
var updatedTasks = tasks
100+
updatedTasks.remove(at: fromIndex)
101+
updatedTasks.insert(draggedTask, at: toIndex)
102+
103+
onReorder(updatedTasks)
104+
self.draggedTask = nil
105+
self.targetIndex = nil
106+
}
105107

106108
return true
107109
}
@@ -145,3 +147,64 @@ private struct TaskDropDelegate: DropDelegate {
145147
return location.y >= (yPosition - buffer) && location.y <= (yPosition + totalItemHeight + buffer)
146148
}
147149
}
150+
151+
struct StateDropAreaModifier: ViewModifier {
152+
let state: TaskViewState
153+
let viewModel: ImpulsoViewModel
154+
@State private var isTargeted = false
155+
156+
func body(content: Content) -> some View {
157+
content
158+
.onDrop(of: [.text], delegate: StateDropDelegate(
159+
targetState: state,
160+
viewModel: viewModel,
161+
isTargeted: $isTargeted
162+
))
163+
.overlay(
164+
RoundedRectangle(cornerRadius: 8)
165+
.stroke(Color.blue.opacity(isTargeted ? 0.3 : 0),
166+
lineWidth: 2)
167+
.animation(.easeOut(duration: 0.2), value: isTargeted)
168+
)
169+
}
170+
}
171+
172+
struct StateDropDelegate: DropDelegate {
173+
let targetState: TaskViewState
174+
let viewModel: ImpulsoViewModel
175+
@Binding var isTargeted: Bool
176+
177+
func performDrop(info: DropInfo) -> Bool {
178+
guard let draggedTask = viewModel.draggedTask else { return false }
179+
180+
// Don't allow dropping into completed state
181+
if targetState == .completed { return false }
182+
183+
withAnimation(.spring(response: 0.3)) {
184+
viewModel.moveTaskToState(draggedTask, state: targetState)
185+
viewModel.draggedTask = nil
186+
viewModel.draggedToIndex = nil
187+
isTargeted = false
188+
}
189+
190+
return true
191+
}
192+
193+
func dropEntered(info: DropInfo) {
194+
guard viewModel.draggedTask != nil else { return }
195+
withAnimation(.easeOut(duration: 0.2)) {
196+
isTargeted = true
197+
}
198+
}
199+
200+
func dropExited(info: DropInfo) {
201+
withAnimation(.easeOut(duration: 0.2)) {
202+
isTargeted = false
203+
}
204+
}
205+
206+
func validateDrop(info: DropInfo) -> Bool {
207+
guard let draggedTask = viewModel.draggedTask else { return false }
208+
return targetState.dropAllowed
209+
}
210+
}

Impulso/Impulso.entitlements

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,7 @@
66
<true/>
77
<key>com.apple.security.files.user-selected.read-only</key>
88
<true/>
9+
<key>com.apple.security.files.user-selected.read-write</key>
10+
<true/>
911
</dict>
1012
</plist>

Impulso/Models/Task.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import SwiftUI
44
import UniformTypeIdentifiers
55

66
// MARK: - TaskMetrics Definition
7-
public struct TaskMetrics: Codable, Equatable {
7+
public struct TaskMetrics: Codable, Equatable, Hashable {
88
var impact: MetricValue
99
var fun: MetricValue
1010
var momentum: MetricValue

Impulso/Persistence/PersistenceController.swift

Lines changed: 48 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -81,32 +81,35 @@ class PersistenceController {
8181
// MARK: - Backup Management
8282

8383
func createBackup() async throws -> URL {
84-
// Create timestamped backup file
85-
let timestamp = ISO8601DateFormatter().string(from: Date())
86-
let backupURL = backupDirectoryURL.appendingPathComponent("Impulso_\(timestamp).backup")
87-
88-
// Ensure backup directory exists
89-
try FileManager.default.createDirectory(
90-
at: backupDirectoryURL,
91-
withIntermediateDirectories: true,
92-
attributes: nil
93-
)
94-
95-
// Save the context to ensure all changes are persisted
96-
let context = container.viewContext
97-
if context.hasChanges {
98-
try context.save()
99-
}
100-
101-
// Get the store URL
102-
guard let storeURL = container.persistentStoreDescriptions.first?.url else {
103-
throw BackupError.exportFailed
84+
return try await withCheckedThrowingContinuation { continuation in
85+
let context = container.newBackgroundContext()
86+
87+
context.performAndWait {
88+
do {
89+
let timestamp = ISO8601DateFormatter().string(from: Date())
90+
let backupURL = backupDirectoryURL.appendingPathComponent("Impulso_\(timestamp).backup")
91+
92+
try FileManager.default.createDirectory(
93+
at: backupDirectoryURL,
94+
withIntermediateDirectories: true,
95+
attributes: nil
96+
)
97+
98+
if context.hasChanges {
99+
try context.save()
100+
}
101+
102+
guard let storeURL = container.persistentStoreDescriptions.first?.url else {
103+
throw BackupError.exportFailed
104+
}
105+
106+
try FileManager.default.copyItem(at: storeURL, to: backupURL)
107+
continuation.resume(returning: backupURL)
108+
} catch {
109+
continuation.resume(throwing: error)
110+
}
111+
}
104112
}
105-
106-
// Copy the store file to backup location
107-
try FileManager.default.copyItem(at: storeURL, to: backupURL)
108-
109-
return backupURL
110113
}
111114

112115
func restoreFromBackup(at url: URL) async throws {
@@ -158,41 +161,49 @@ class PersistenceController {
158161
func exportData() async throws -> URL {
159162
let encoder = JSONEncoder()
160163
encoder.dateEncodingStrategy = .iso8601
161-
encoder.outputFormatting = .prettyPrinted
164+
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
162165

163166
let fetchRequest: NSFetchRequest<ImpulsoTask> = ImpulsoTask.fetchRequest()
164-
165167
let tasks = try container.viewContext.fetch(fetchRequest)
166168
let taskData = try encoder.encode(tasks.map(TaskData.init))
167169

168-
let exportURL = FileManager.default.temporaryDirectory
169-
.appendingPathComponent("Impulso_Export_\(Date().timeIntervalSince1970).json")
170+
// Create a unique filename with safe characters
171+
let timestamp = Date().formatForFilename()
172+
let filename = "Impulso_Export_\(timestamp).json"
173+
174+
// Use the documents directory instead of temporary
175+
let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
176+
let exportURL = documentsURL.appendingPathComponent(filename)
170177

171-
try taskData.write(to: exportURL)
178+
try taskData.write(to: exportURL, options: .atomic)
172179
return exportURL
173180
}
174181

175182
func importData(from url: URL) async throws {
183+
guard FileManager.default.fileExists(atPath: url.path) else {
184+
throw BackupError.fileNotFound
185+
}
186+
176187
let data = try Data(contentsOf: url)
177188
let decoder = JSONDecoder()
178189
decoder.dateDecodingStrategy = .iso8601
179190

180191
let taskData = try decoder.decode([TaskData].self, from: data)
181192

182-
// Create new background context for import
183193
let importContext = container.newBackgroundContext()
184-
185194
try await importContext.perform {
186-
// Create new tasks from imported data
195+
// Clear existing tasks first
196+
let fetchRequest: NSFetchRequest<NSFetchRequestResult> = ImpulsoTask.fetchRequest()
197+
let batchDelete = NSBatchDeleteRequest(fetchRequest: fetchRequest)
198+
try importContext.execute(batchDelete)
199+
200+
// Import new tasks
187201
for taskInfo in taskData {
188202
let newTask = ImpulsoTask(context: importContext)
189203
newTask.update(from: taskInfo)
190204
}
191205

192-
// Save imported tasks
193-
if importContext.hasChanges {
194-
try importContext.save()
195-
}
206+
try importContext.save()
196207
}
197208
}
198209

Impulso/Resources/MenuBarController.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ class MenuBarController: NSObject, ObservableObject {
4040
}
4141

4242
@objc private func checkForUpdates() {
43-
updater.checkForUpdates()
43+
self.updater.checkForUpdates()
4444
}
4545

4646
@objc private func downloadUpdate() {

Impulso/Resources/UpdateChecker.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Foundation
2+
import Combine
23

34
struct GitHubRelease: Codable {
45
let tagName: String
@@ -29,6 +30,7 @@ class UpdateChecker: ObservableObject {
2930
private let currentVersion: String
3031
private let githubRepo: String
3132
private var updateCheckTimer: Timer?
33+
private var cancellables = Set<AnyCancellable>()
3234

3335
init() {
3436
self.currentVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0.0"
@@ -178,5 +180,6 @@ class UpdateChecker: ObservableObject {
178180

179181
deinit {
180182
updateCheckTimer?.invalidate()
183+
cancellables.forEach { $0.cancel() }
181184
}
182185
}

Impulso/UI Components/CommandMenu.swift

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ struct CommandMenu: View {
88
@FocusState private var isFocused: Bool
99
@Environment(\.colorScheme) private var colorScheme
1010
@Environment(\.managedObjectContext) private var viewContext
11+
@State private var searchDebouncer: Timer?
1112

1213
var body: some View {
1314
VStack(spacing: 0) {
@@ -21,7 +22,7 @@ struct CommandMenu: View {
2122
.textFieldStyle(PlainTextFieldStyle())
2223
.focused($isFocused)
2324
.onChange(of: text) { _, newValue in
24-
filterTasks(query: newValue)
25+
debouncedSearch(newValue)
2526
}
2627
.onSubmit {
2728
if !text.isEmpty {
@@ -122,6 +123,10 @@ struct CommandMenu: View {
122123
.onAppear {
123124
isFocused = true
124125
}
126+
.onDisappear {
127+
searchDebouncer?.invalidate()
128+
searchDebouncer = nil
129+
}
125130
}
126131

127132
private func filterTasks(query: String) {
@@ -143,6 +148,14 @@ struct CommandMenu: View {
143148
filteredTasks = []
144149
}
145150
}
151+
152+
private func debouncedSearch(_ query: String) {
153+
searchDebouncer?.invalidate()
154+
searchDebouncer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: false) { _ in
155+
filterTasks(query: query)
156+
searchDebouncer = nil
157+
}
158+
}
146159
}
147160

148161
struct CommandMenuButtonStyle: ButtonStyle {

0 commit comments

Comments
 (0)