From 95c98a4e35bbc4ecf65a3e149e7eda175b3be622 Mon Sep 17 00:00:00 2001 From: Piyush Thakkar Date: Sun, 29 Mar 2026 01:47:36 +0530 Subject: [PATCH] feat: add network monitor, rate limiting, profile stats, email validation, and UI/perf fixes Adds NetworkMonitor for offline banner, Gemini API rate limiting, profile note/word stats, email validation on auth, adaptive sidebar width, HTML-wrapped AI responses, RichTextEditor caching, filteredNotes memoization, iPad multi-window share sheet fix, and keyboard shortcuts. --- GlobalNotes.xcodeproj/project.pbxproj | 4 +++ GlobalNotes/App/GlobalNotesApp.swift | 2 ++ GlobalNotes/Services/GeminiService.swift | 12 ++++++++ GlobalNotes/Services/SyncEngine.swift | 23 +++++++-------- GlobalNotes/Utilities/NetworkMonitor.swift | 28 ++++++++++++++++++ GlobalNotes/ViewModels/AuthViewModel.swift | 8 +++++ .../ViewModels/NotesListViewModel.swift | 18 ++++++++++++ GlobalNotes/ViewModels/ProfileViewModel.swift | 3 +- GlobalNotes/Views/AI/AIAssistantView.swift | 12 ++++++-- GlobalNotes/Views/Auth/LoginView.swift | 2 +- .../CodeWorkspace/CodeWorkspaceView.swift | 4 ++- .../Views/Components/FormattingToolbar.swift | 9 ++++-- .../Views/Components/RichTextEditor.swift | 23 ++++++++------- .../Views/Components/ShapesPickerView.swift | 12 ++++---- .../Views/Components/TagInputView.swift | 2 ++ GlobalNotes/Views/Notes/MainAppView.swift | 19 ++++++++++++ GlobalNotes/Views/Notes/NoteEditorView.swift | 29 +++++++++++++++---- GlobalNotes/Views/Notes/NotesListView.swift | 4 ++- GlobalNotes/Views/Profile/ProfileView.swift | 27 +++++++++++++++++ GlobalNotes/Views/Settings/SettingsView.swift | 6 ++-- .../Views/Tools/AudioRecorderView.swift | 2 +- .../Views/Tools/MailGeneratorView.swift | 1 + .../Views/Tools/SmartCalendarView.swift | 2 ++ 23 files changed, 205 insertions(+), 47 deletions(-) create mode 100644 GlobalNotes/Utilities/NetworkMonitor.swift diff --git a/GlobalNotes.xcodeproj/project.pbxproj b/GlobalNotes.xcodeproj/project.pbxproj index a711b73..83db74d 100644 --- a/GlobalNotes.xcodeproj/project.pbxproj +++ b/GlobalNotes.xcodeproj/project.pbxproj @@ -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 */; }; @@ -100,6 +101,7 @@ 0C11692D7E5CBC2E025C539B /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; 1C8667C615A5DB1729C201AF /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 2BC659F70936C78D7AE5C32F /* HapticManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticManager.swift; sourceTree = ""; }; + A1B2C3D4E5F60719 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = ""; }; 3271194C2AF9AAD9BEE1CF2A /* Note.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Note.swift; sourceTree = ""; }; 3D01E338E23A0DDB7D1AED44 /* ThemePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePicker.swift; sourceTree = ""; }; 45F8B1BEA33BAC7B19B1EF10 /* AIAssistantView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIAssistantView.swift; sourceTree = ""; }; @@ -196,6 +198,7 @@ 83C8FBFB3582AF9BEFB8D086 /* Constants.swift */, D27BFEDBBC15F50570704221 /* Extensions.swift */, 2BC659F70936C78D7AE5C32F /* HapticManager.swift */, + A1B2C3D4E5F60719 /* NetworkMonitor.swift */, 40D81E44948CD21C22F34581 /* LanguageMap.swift */, ); path = Utilities; @@ -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 */, diff --git a/GlobalNotes/App/GlobalNotesApp.swift b/GlobalNotes/App/GlobalNotesApp.swift index cef660f..89bfaa4 100644 --- a/GlobalNotes/App/GlobalNotesApp.swift +++ b/GlobalNotes/App/GlobalNotesApp.swift @@ -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]) diff --git a/GlobalNotes/Services/GeminiService.swift b/GlobalNotes/Services/GeminiService.swift index 62aeba7..1c69356 100644 --- a/GlobalNotes/Services/GeminiService.swift +++ b/GlobalNotes/Services/GeminiService.swift @@ -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? { @@ -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 } @@ -91,6 +100,7 @@ final class GeminiService { case notConfigured case invalidResponse case apiError(String) + case rateLimited var errorDescription: String? { switch self { @@ -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." } } } diff --git a/GlobalNotes/Services/SyncEngine.swift b/GlobalNotes/Services/SyncEngine.swift index bc16505..4dfe7a2 100644 --- a/GlobalNotes/Services/SyncEngine.swift +++ b/GlobalNotes/Services/SyncEngine.swift @@ -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 = 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 @@ -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, @@ -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) @@ -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)") @@ -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 { @@ -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)") @@ -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) @@ -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 } @@ -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 { @@ -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)") } } } diff --git a/GlobalNotes/Utilities/NetworkMonitor.swift b/GlobalNotes/Utilities/NetworkMonitor.swift new file mode 100644 index 0000000..0c44e1b --- /dev/null +++ b/GlobalNotes/Utilities/NetworkMonitor.swift @@ -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() + } +} diff --git a/GlobalNotes/ViewModels/AuthViewModel.swift b/GlobalNotes/ViewModels/AuthViewModel.swift index 1432e13..c5ccbf3 100644 --- a/GlobalNotes/ViewModels/AuthViewModel.swift +++ b/GlobalNotes/ViewModels/AuthViewModel.swift @@ -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 @@ -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 diff --git a/GlobalNotes/ViewModels/NotesListViewModel.swift b/GlobalNotes/ViewModels/NotesListViewModel.swift index f1676c5..e3db433 100644 --- a/GlobalNotes/ViewModels/NotesListViewModel.swift +++ b/GlobalNotes/ViewModels/NotesListViewModel.swift @@ -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 @@ -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 + } } diff --git a/GlobalNotes/ViewModels/ProfileViewModel.swift b/GlobalNotes/ViewModels/ProfileViewModel.swift index 917c85f..e87f8ce 100644 --- a/GlobalNotes/ViewModels/ProfileViewModel.swift +++ b/GlobalNotes/ViewModels/ProfileViewModel.swift @@ -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() diff --git a/GlobalNotes/Views/AI/AIAssistantView.swift b/GlobalNotes/Views/AI/AIAssistantView.swift index c39b86d..e8e30ba 100644 --- a/GlobalNotes/Views/AI/AIAssistantView.swift +++ b/GlobalNotes/Views/AI/AIAssistantView.swift @@ -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 { "

\($0)

" } + .joined() + editorVM.htmlContent = htmlResponse HapticManager.notification(.success) dismiss() } label: { @@ -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 { "

\($0)

" } + .joined() + editorVM.htmlContent += htmlResponse HapticManager.notification(.success) dismiss() } label: { diff --git a/GlobalNotes/Views/Auth/LoginView.swift b/GlobalNotes/Views/Auth/LoginView.swift index 1ee2503..1f865b6 100644 --- a/GlobalNotes/Views/Auth/LoginView.swift +++ b/GlobalNotes/Views/Auth/LoginView.swift @@ -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) diff --git a/GlobalNotes/Views/CodeWorkspace/CodeWorkspaceView.swift b/GlobalNotes/Views/CodeWorkspace/CodeWorkspaceView.swift index 9ed1974..b0f11f8 100644 --- a/GlobalNotes/Views/CodeWorkspace/CodeWorkspaceView.swift +++ b/GlobalNotes/Views/CodeWorkspace/CodeWorkspaceView.swift @@ -53,7 +53,7 @@ struct CodeWorkspaceView: View { } .listStyle(.plain) } - .frame(width: 200) + .frame(minWidth: 160, idealWidth: 200, maxWidth: 260) Divider() @@ -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) diff --git a/GlobalNotes/Views/Components/FormattingToolbar.swift b/GlobalNotes/Views/Components/FormattingToolbar.swift index eb7b07d..564b15e 100644 --- a/GlobalNotes/Views/Components/FormattingToolbar.swift +++ b/GlobalNotes/Views/Components/FormattingToolbar.swift @@ -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) @@ -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) diff --git a/GlobalNotes/Views/Components/RichTextEditor.swift b/GlobalNotes/Views/Components/RichTextEditor.swift index 265bc16..b004bb0 100644 --- a/GlobalNotes/Views/Components/RichTextEditor.swift +++ b/GlobalNotes/Views/Components/RichTextEditor.swift @@ -35,17 +35,19 @@ struct RichTextEditor: UIViewRepresentable { // Only update if the HTML actually changed externally (e.g., note switch) guard context.coordinator.shouldUpdateContent else { return } - let newAttributed = HTMLConverter.attributedString(from: htmlContent) - let currentHTML = HTMLConverter.html(from: textView.attributedText) + // Skip expensive conversion if content matches what we last set + guard htmlContent != context.coordinator.lastSetHTML else { + context.coordinator.shouldUpdateContent = false + return + } + + let selectedRange = textView.selectedRange + textView.attributedText = HTMLConverter.attributedString(from: htmlContent) + context.coordinator.lastSetHTML = htmlContent - // Avoid unnecessary updates that would reset cursor position - if currentHTML != htmlContent { - let selectedRange = textView.selectedRange - textView.attributedText = newAttributed - // Restore cursor if possible - if selectedRange.location <= textView.text.count { - textView.selectedRange = selectedRange - } + // Restore cursor if possible + if selectedRange.location <= textView.text.count { + textView.selectedRange = selectedRange } context.coordinator.shouldUpdateContent = false } @@ -55,6 +57,7 @@ struct RichTextEditor: UIViewRepresentable { class Coordinator: NSObject, UITextViewDelegate { var parent: RichTextEditor var shouldUpdateContent = true + var lastSetHTML: String = "" init(_ parent: RichTextEditor) { self.parent = parent diff --git a/GlobalNotes/Views/Components/ShapesPickerView.swift b/GlobalNotes/Views/Components/ShapesPickerView.swift index 22f63b6..dffc334 100644 --- a/GlobalNotes/Views/Components/ShapesPickerView.swift +++ b/GlobalNotes/Views/Components/ShapesPickerView.swift @@ -50,17 +50,17 @@ struct ShapesPickerView: View { let svg: String switch shape { case "Rectangle": - svg = "" + svg = "" case "Circle": - svg = "" + svg = "" case "Triangle": - svg = "" + svg = "" case "Star": - svg = "" + svg = "" case "Arrow": - svg = "" + svg = "" case "Line": - svg = "" + svg = "" default: svg = "" } diff --git a/GlobalNotes/Views/Components/TagInputView.swift b/GlobalNotes/Views/Components/TagInputView.swift index c655832..3da4890 100644 --- a/GlobalNotes/Views/Components/TagInputView.swift +++ b/GlobalNotes/Views/Components/TagInputView.swift @@ -64,6 +64,8 @@ struct TagChip: View { Button(action: onRemove) { Image(systemName: "xmark") .font(.system(size: 8, weight: .bold)) + .frame(width: 22, height: 22) + .contentShape(Circle()) } .accessibilityLabel("Remove \(tag)") } diff --git a/GlobalNotes/Views/Notes/MainAppView.swift b/GlobalNotes/Views/Notes/MainAppView.swift index 95074d5..f38e3ed 100644 --- a/GlobalNotes/Views/Notes/MainAppView.swift +++ b/GlobalNotes/Views/Notes/MainAppView.swift @@ -11,6 +11,7 @@ struct MainAppView: View { @State private var showCodeWorkspace = false @State private var showMailGenerator = false @State private var showCalendar = false + @StateObject private var networkMonitor = NetworkMonitor.shared var body: some View { Group { @@ -73,6 +74,23 @@ struct MainAppView: View { } } .animation(.spring(response: 0.3), value: viewModel.errorMessage) + .overlay(alignment: .bottom) { + if !networkMonitor.isConnected { + HStack(spacing: 6) { + Image(systemName: "wifi.slash") + .font(.caption2) + Text("Offline — changes saved locally") + .font(.caption2) + } + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.orange.gradient, in: Capsule()) + .padding(.bottom, 4) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .animation(.spring(response: 0.3), value: networkMonitor.isConnected) + } + } .sheet(isPresented: $showProfile) { ProfileView() } @@ -263,6 +281,7 @@ struct SidebarWithListView: View { Image(systemName: "line.3.horizontal") .font(.title3) } + .accessibilityLabel("Menu") } } .alert("New Folder", isPresented: $showNewFolderAlert) { diff --git a/GlobalNotes/Views/Notes/NoteEditorView.swift b/GlobalNotes/Views/Notes/NoteEditorView.swift index 65810d2..76896e6 100644 --- a/GlobalNotes/Views/Notes/NoteEditorView.swift +++ b/GlobalNotes/Views/Notes/NoteEditorView.swift @@ -89,6 +89,7 @@ struct NoteEditorView: View { Button { showShareSheet = true } label: { Image(systemName: "square.and.arrow.up") } + .accessibilityLabel("Share note") // Insert menu Menu { @@ -101,6 +102,7 @@ struct NoteEditorView: View { } label: { Image(systemName: "plus.circle") } + .accessibilityLabel("Insert content") // More menu Menu { @@ -241,6 +243,11 @@ struct NoteEditorView: View { .onChange(of: note.id) { editorVM.load(note: note) } + .keyboardShortcut("s", modifiers: .command) + .onKeyPress(.init("s"), phases: .down) { _ in + Task { await editorVM.save(context: modelContext) } + return .handled + } } // MARK: - Editor Toolbar @@ -327,14 +334,22 @@ struct ExportSheetView: View { private func shareText(_ text: String, filename: String) { let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename) - try? text.write(to: tempURL, atomically: true, encoding: .utf8) - share(items: [tempURL]) + do { + try text.write(to: tempURL, atomically: true, encoding: .utf8) + share(items: [tempURL]) + } catch { + print("Export error: \(error.localizedDescription)") + } } private func shareData(_ data: Data, filename: String, mimeType: String) { let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename) - try? data.write(to: tempURL) - share(items: [tempURL]) + do { + try data.write(to: tempURL) + share(items: [tempURL]) + } catch { + print("Export error: \(error.localizedDescription)") + } } private func printNote() { @@ -349,8 +364,10 @@ struct ExportSheetView: View { } private func share(items: [Any]) { - guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = scene.windows.first, + guard let scene = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first(where: { $0.activationState == .foregroundActive }), + let window = scene.windows.first(where: { $0.isKeyWindow }), let rootVC = window.rootViewController else { return } let activityVC = UIActivityViewController(activityItems: items, applicationActivities: nil) activityVC.popoverPresentationController?.sourceView = rootVC.view diff --git a/GlobalNotes/Views/Notes/NotesListView.swift b/GlobalNotes/Views/Notes/NotesListView.swift index 9e0fe7e..eacf143 100644 --- a/GlobalNotes/Views/Notes/NotesListView.swift +++ b/GlobalNotes/Views/Notes/NotesListView.swift @@ -35,7 +35,9 @@ struct NotesListView: View { } .swipeActions(edge: .leading) { Button { - Task { @MainActor in await viewModel.toggleFavorite(note, context: modelContext) } + Task { @MainActor in + await viewModel.toggleFavorite(note, context: modelContext) + } } label: { Label( note.isFavorite ? "Unfavorite" : "Favorite", diff --git a/GlobalNotes/Views/Profile/ProfileView.swift b/GlobalNotes/Views/Profile/ProfileView.swift index b6a2f8b..7b7a8e1 100644 --- a/GlobalNotes/Views/Profile/ProfileView.swift +++ b/GlobalNotes/Views/Profile/ProfileView.swift @@ -1,10 +1,14 @@ import SwiftUI +import SwiftData struct ProfileView: View { @EnvironmentObject var authViewModel: AuthViewModel @StateObject private var profileVM = ProfileViewModel() @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext @State private var showSignOutConfirm = false + @State private var totalNotes = 0 + @State private var totalWords = 0 var body: some View { NavigationStack { @@ -45,6 +49,18 @@ struct ProfileView: View { // Stats Section("Notes") { + HStack { + Label("Total Notes", systemImage: "doc.text") + Spacer() + Text("\(totalNotes)") + .foregroundStyle(.secondary) + } + HStack { + Label("Total Words", systemImage: "textformat.abc") + Spacer() + Text("\(totalWords)") + .foregroundStyle(.secondary) + } HStack { Label("Sync Status", systemImage: "arrow.triangle.2.circlepath") Spacer() @@ -98,10 +114,21 @@ struct ProfileView: View { .overlay { if profileVM.isLoading { ProgressView() + .controlSize(.large) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.ultraThinMaterial) } } .task { await profileVM.loadProfile() + // Calculate stats + let descriptor = FetchDescriptor() + if let notes = try? modelContext.fetch(descriptor) { + totalNotes = notes.count + totalWords = notes.reduce(0) { sum, note in + sum + note.content.strippingHTML.split(whereSeparator: { $0.isWhitespace }).count + } + } } } } diff --git a/GlobalNotes/Views/Settings/SettingsView.swift b/GlobalNotes/Views/Settings/SettingsView.swift index bd8052d..c0d84ab 100644 --- a/GlobalNotes/Views/Settings/SettingsView.swift +++ b/GlobalNotes/Views/Settings/SettingsView.swift @@ -63,8 +63,10 @@ struct SettingsView: View { .foregroundStyle(.secondary) } - Link(destination: URL(string: "https://github.com/Coder-s-OG-s/Global_Notes-ios")!) { - Label("GitHub Repository", systemImage: "link") + if let url = URL(string: "https://github.com/Coder-s-OG-s/Global_Notes-ios") { + Link(destination: url) { + Label("GitHub Repository", systemImage: "link") + } } } } diff --git a/GlobalNotes/Views/Tools/AudioRecorderView.swift b/GlobalNotes/Views/Tools/AudioRecorderView.swift index 4ad583c..a233215 100644 --- a/GlobalNotes/Views/Tools/AudioRecorderView.swift +++ b/GlobalNotes/Views/Tools/AudioRecorderView.swift @@ -43,7 +43,7 @@ struct AudioRecorderView: View { Button { let html = """
🎙️ Audio Recording — \(formattedDuration) diff --git a/GlobalNotes/Views/Tools/MailGeneratorView.swift b/GlobalNotes/Views/Tools/MailGeneratorView.swift index 54f4058..21d7b8e 100644 --- a/GlobalNotes/Views/Tools/MailGeneratorView.swift +++ b/GlobalNotes/Views/Tools/MailGeneratorView.swift @@ -68,6 +68,7 @@ struct MailGeneratorView: View { } } } + .disabled(viewModel.isGenerating) .navigationTitle("Mail Generator") .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/GlobalNotes/Views/Tools/SmartCalendarView.swift b/GlobalNotes/Views/Tools/SmartCalendarView.swift index bd81630..0d9e774 100644 --- a/GlobalNotes/Views/Tools/SmartCalendarView.swift +++ b/GlobalNotes/Views/Tools/SmartCalendarView.swift @@ -17,6 +17,7 @@ struct SmartCalendarView: View { Button { viewModel.previousMonth() } label: { Image(systemName: "chevron.left") } + .accessibilityLabel("Previous month") Spacer() Text("\(viewModel.monthName) \(String(viewModel.currentYear))") .font(.headline) @@ -24,6 +25,7 @@ struct SmartCalendarView: View { Button { viewModel.nextMonth() } label: { Image(systemName: "chevron.right") } + .accessibilityLabel("Next month") } .padding()