Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions GlobalNotes.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
EBF7C1CA96F3317605FC8B3B /* NoteRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B2216CF1F79E16680520388 /* NoteRowView.swift */; };
F0214A41303B62088BCDE0A8 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B08C09D5AA1588C89FEEB2F5 /* LoginView.swift */; };
FBFDC920D88B9AFD4E500052 /* HapticManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BC659F70936C78D7AE5C32F /* HapticManager.swift */; };
A1B2C3D4E5F60718 /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60719 /* NetworkMonitor.swift */; };
FF97916DB33F47C1566C650B /* NoteEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93F9603A7C5A36BC01F15401 /* NoteEditorView.swift */; };
34CCBD4349D23CB8302B31A2 /* AppThemeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0CCC9DB17959F735575EB0E /* AppThemeView.swift */; };
B3EE344EB709CC8453743B2C /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = C975179C4D6737450967D6AE /* AppTheme.swift */; };
Expand Down Expand Up @@ -100,6 +101,7 @@
0C11692D7E5CBC2E025C539B /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; };
1C8667C615A5DB1729C201AF /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
2BC659F70936C78D7AE5C32F /* HapticManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticManager.swift; sourceTree = "<group>"; };
A1B2C3D4E5F60719 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = "<group>"; };
3271194C2AF9AAD9BEE1CF2A /* Note.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Note.swift; sourceTree = "<group>"; };
3D01E338E23A0DDB7D1AED44 /* ThemePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePicker.swift; sourceTree = "<group>"; };
45F8B1BEA33BAC7B19B1EF10 /* AIAssistantView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIAssistantView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -196,6 +198,7 @@
83C8FBFB3582AF9BEFB8D086 /* Constants.swift */,
D27BFEDBBC15F50570704221 /* Extensions.swift */,
2BC659F70936C78D7AE5C32F /* HapticManager.swift */,
A1B2C3D4E5F60719 /* NetworkMonitor.swift */,
40D81E44948CD21C22F34581 /* LanguageMap.swift */,
);
path = Utilities;
Expand Down Expand Up @@ -552,6 +555,7 @@
6BBB60CCC83FCD4C9CD3CA92 /* GlobalNotesApp.swift in Sources */,
AA38E0CBC1416A589276BFFB /* HTMLConverter.swift in Sources */,
FBFDC920D88B9AFD4E500052 /* HapticManager.swift in Sources */,
A1B2C3D4E5F60718 /* NetworkMonitor.swift in Sources */,
EF42982E7EBBE95DC6040FEF /* LanguageMap.swift in Sources */,
F0214A41303B62088BCDE0A8 /* LoginView.swift in Sources */,
7ABE07177F247A8489030CDB /* MailGeneratorView.swift in Sources */,
Expand Down
2 changes: 2 additions & 0 deletions GlobalNotes/App/GlobalNotesApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ struct GlobalNotesApp: App {
CodeSnippetItem.self
])

// Migration plan: when schema changes, SwiftData auto-migrates lightweight changes.
// For breaking changes, add a SchemaMigrationPlan here.
do {
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
modelContainer = try ModelContainer(for: schema, configurations: [config])
Expand Down
12 changes: 12 additions & 0 deletions GlobalNotes/Services/GeminiService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import Foundation
final class GeminiService {
static let shared = GeminiService()

private var lastRequestTime: Date?
private let minimumInterval: TimeInterval = 2.0 // seconds between requests

private init() {}

private var apiURL: URL? {
Expand Down Expand Up @@ -50,6 +53,12 @@ final class GeminiService {
}

func generateText(prompt: String) async throws -> String {
// Rate limiting
if let last = lastRequestTime, Date.now.timeIntervalSince(last) < minimumInterval {
throw GeminiError.rateLimited
}
lastRequestTime = .now

guard let url = apiURL else {
throw GeminiError.notConfigured
}
Expand Down Expand Up @@ -91,6 +100,7 @@ final class GeminiService {
case notConfigured
case invalidResponse
case apiError(String)
case rateLimited

var errorDescription: String? {
switch self {
Expand All @@ -100,6 +110,8 @@ final class GeminiService {
return "Invalid response from AI service."
case .apiError(let message):
return "AI Error: \(message)"
case .rateLimited:
return "Please wait a moment before sending another request."
}
}
}
Expand Down
23 changes: 10 additions & 13 deletions GlobalNotes/Services/SyncEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,12 @@ final class SyncEngine: ObservableObject {

// 5. Merge: newer wins by updatedAt, preserve local-only and cloud-only
var mergedNotes: [NoteItem] = []
var cloudOnlyIds: Set<String> = Set(cloudMap.keys)

for id in allIds {
let cloudNote = cloudMap[id]
let localNote = localMap[id]

if let cloud = cloudNote, let local = localNote {
cloudOnlyIds.remove(id)
// Both exist — newer wins
if cloud.updatedAt > local.updatedAt {
// Update local with cloud data
Expand All @@ -105,7 +103,6 @@ final class SyncEngine: ObservableObject {
// Local only — keep it, may need sync
mergedNotes.append(local)
} else if let cloud = cloudNote {
cloudOnlyIds.remove(id)
// Cloud only — insert into SwiftData
let item = NoteItem(
id: cloud.id,
Expand Down Expand Up @@ -137,7 +134,7 @@ final class SyncEngine: ObservableObject {
}
}

try? context.save()
do { try context.save() } catch { print("SwiftData save error: \(error.localizedDescription)") }

// 7. Sync unsynced local notes to cloud
await syncPendingNotes(context: context)
Expand All @@ -149,14 +146,14 @@ final class SyncEngine: ObservableObject {
func saveNote(_ note: NoteItem, context: ModelContext) async {
note.updatedAt = .now
note.isSynced = false
try? context.save()
do { try context.save() } catch { print("SwiftData save error: \(error.localizedDescription)") }

// Sync to cloud in background
if await authService.getCurrentSession() != nil {
do {
try await noteService.upsertNote(note)
note.isSynced = true
try? context.save()
do { try context.save() } catch { print("SwiftData save error: \(error.localizedDescription)") }
} catch {
lastSyncError = error.localizedDescription
print("Cloud sync failed for note \(note.id): \(error.localizedDescription)")
Expand All @@ -168,7 +165,7 @@ final class SyncEngine: ObservableObject {
func deleteNote(_ note: NoteItem, context: ModelContext) async {
let noteId = note.id
context.delete(note)
try? context.save()
do { try context.save() } catch { print("SwiftData save error: \(error.localizedDescription)") }

// Delete from cloud
if await authService.getCurrentSession() != nil {
Expand All @@ -195,7 +192,7 @@ final class SyncEngine: ObservableObject {
for note in unsyncedNotes {
note.isSynced = true
}
try? context.save()
do { try context.save() } catch { print("SwiftData save error: \(error.localizedDescription)") }
} catch {
lastSyncError = error.localizedDescription
print("Batch sync failed: \(error.localizedDescription)")
Expand Down Expand Up @@ -274,7 +271,7 @@ final class SyncEngine: ObservableObject {
}
}

try? context.save()
do { try context.save() } catch { print("SwiftData save error: \(error.localizedDescription)") }

// Sync unsynced folders
await syncPendingFolders(context: context)
Expand All @@ -285,13 +282,13 @@ final class SyncEngine: ObservableObject {
/// Save folder locally and sync to cloud
func saveFolder(_ folder: FolderItem, context: ModelContext) async {
folder.isSynced = false
try? context.save()
do { try context.save() } catch { print("SwiftData save error: \(error.localizedDescription)") }

if await authService.getCurrentSession() != nil {
do {
try await folderService.upsertFolder(folder)
folder.isSynced = true
try? context.save()
do { try context.save() } catch { print("SwiftData save error: \(error.localizedDescription)") }
} catch {
lastSyncError = error.localizedDescription
}
Expand All @@ -302,7 +299,7 @@ final class SyncEngine: ObservableObject {
func deleteFolder(_ folder: FolderItem, context: ModelContext) async {
let folderId = folder.id
context.delete(folder)
try? context.save()
do { try context.save() } catch { print("SwiftData save error: \(error.localizedDescription)") }

if await authService.getCurrentSession() != nil {
do {
Expand Down Expand Up @@ -330,6 +327,6 @@ final class SyncEngine: ObservableObject {
lastSyncError = error.localizedDescription
}
}
try? context.save()
do { try context.save() } catch { print("SwiftData save error: \(error.localizedDescription)") }
}
}
28 changes: 28 additions & 0 deletions GlobalNotes/Utilities/NetworkMonitor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import Network
import SwiftUI

/// Monitors network connectivity using NWPathMonitor.
@MainActor
final class NetworkMonitor: ObservableObject {
static let shared = NetworkMonitor()

@Published var isConnected = true
@Published var connectionType: NWInterface.InterfaceType?

private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "NetworkMonitor")

private init() {
monitor.pathUpdateHandler = { [weak self] path in
Task { @MainActor [weak self] in
self?.isConnected = path.status == .satisfied
self?.connectionType = path.availableInterfaces.first?.type
}
}
monitor.start(queue: queue)
}

deinit {
monitor.cancel()
}
}
8 changes: 8 additions & 0 deletions GlobalNotes/ViewModels/AuthViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ final class AuthViewModel: ObservableObject {
errorMessage = "Please enter both email and password."
return
}
guard email.contains("@"), email.contains(".") else {
errorMessage = "Please enter a valid email address."
return
}

errorMessage = nil
isSigningIn = true
Expand All @@ -70,6 +74,10 @@ final class AuthViewModel: ObservableObject {
errorMessage = "Please enter both email and password."
return
}
guard email.contains("@"), email.contains(".") else {
errorMessage = "Please enter a valid email address."
return
}
guard password.count >= 6 else {
errorMessage = "Password must be at least 6 characters."
return
Expand Down
18 changes: 18 additions & 0 deletions GlobalNotes/ViewModels/NotesListViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,23 @@ final class NotesListViewModel: ObservableObject {
@Published var errorMessage: String?

private let syncEngine = SyncEngine.shared
private var _cachedFilteredNotes: [NoteItem]?
private var _lastFilterKey: String?

// MARK: - Filtered & Sorted Notes

var filteredNotes: [NoteItem] {
let key = "\(libraryFilter.rawValue)-\(selectedFolderId ?? "")-\(searchText)-\(sortOption.rawValue)-\(notes.count)"
if let cached = _cachedFilteredNotes, key == _lastFilterKey {
return cached
}
let result = computeFilteredNotes()
_cachedFilteredNotes = result
_lastFilterKey = key
return result
}

private func computeFilteredNotes() -> [NoteItem] {
var result = notes

// Library filter
Expand Down Expand Up @@ -233,6 +246,11 @@ final class NotesListViewModel: ObservableObject {
func selectLibraryFilter(_ filter: LibraryFilter) {
libraryFilter = filter
selectedFolderId = nil
_cachedFilteredNotes = nil
HapticManager.selection()
}

private func invalidateCache() {
_cachedFilteredNotes = nil
}
}
3 changes: 2 additions & 1 deletion GlobalNotes/ViewModels/ProfileViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ final class ProfileViewModel: ObservableObject {
errorMessage = nil

do {
let user = try await client.auth.session.user
let session = try await client.auth.session
let user = session.user
let response: UserProfile? = try? await client
.from("profiles")
.select()
Expand Down
12 changes: 10 additions & 2 deletions GlobalNotes/Views/AI/AIAssistantView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,11 @@ struct AIAssistantView: View {

HStack {
Button {
editorVM.htmlContent = response
let htmlResponse = response.components(separatedBy: "\n")
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
.map { "<p>\($0)</p>" }
.joined()
editorVM.htmlContent = htmlResponse
HapticManager.notification(.success)
dismiss()
} label: {
Expand All @@ -139,7 +143,11 @@ struct AIAssistantView: View {
.controlSize(.small)

Button {
editorVM.htmlContent += "\n\n" + response
let htmlResponse = response.components(separatedBy: "\n")
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
.map { "<p>\($0)</p>" }
.joined()
editorVM.htmlContent += htmlResponse
HapticManager.notification(.success)
dismiss()
} label: {
Expand Down
2 changes: 1 addition & 1 deletion GlobalNotes/Views/Auth/LoginView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ struct LoginView: View {
StyledSecureField(placeholder: "Password", text: $password, icon: "lock")

Button {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) // dismiss keyboard
Task {
if isSignUp {
await authViewModel.signUp(email: email, password: password, username: username)
Expand Down
4 changes: 3 additions & 1 deletion GlobalNotes/Views/CodeWorkspace/CodeWorkspaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ struct CodeWorkspaceView: View {
}
.listStyle(.plain)
}
.frame(width: 200)
.frame(minWidth: 160, idealWidth: 200, maxWidth: 260)

Divider()

Expand Down Expand Up @@ -133,12 +133,14 @@ struct CodeWorkspaceView: View {
Image(systemName: viewModel.isGenerating ? "hourglass" : "paperplane.fill")
}
.disabled(viewModel.userInput.trimmingCharacters(in: .whitespaces).isEmpty || viewModel.isGenerating)
.accessibilityLabel("Send message")

Button {
showAIAssistant = true
} label: {
Image(systemName: "sparkles")
}
.accessibilityLabel("AI Assistant")
}
.padding(.horizontal)
.padding(.bottom, 8)
Expand Down
9 changes: 6 additions & 3 deletions GlobalNotes/Views/Components/FormattingToolbar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ struct FormattingToolbar: View {
private func applyFormatting(_ type: FormatType) {
guard let textView = findFirstResponderTextView() else { return }
let range = textView.selectedRange
guard range.length > 0 else { return }
guard range.length > 0 else {
HapticManager.notification(.warning)
return
}

let mutableAttr = NSMutableAttributedString(attributedString: textView.attributedText)

Expand Down Expand Up @@ -154,8 +157,8 @@ struct FormatButton: View {
var body: some View {
Button(action: action) {
Image(systemName: icon)
.font(.system(size: 14, weight: .medium))
.frame(width: 36, height: 32)
.font(.system(size: 15, weight: .medium))
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Expand Down
Loading