Repository: dena-sohrabi/There Branch: main Commit: 61863ffb560c Files: 60 Total size: 111.5 KB Directory structure: gitextract_iq1mvdbh/ ├── .gitignore ├── LICENSE ├── There/ │ ├── Assets.xcassets/ │ │ ├── AccentColor.colorset/ │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset/ │ │ │ └── Contents.json │ │ ├── Contents.json │ │ ├── Earth.imageset/ │ │ │ └── Contents.json │ │ ├── Logo.imageset/ │ │ │ └── Contents.json │ │ ├── Night.imageset/ │ │ │ └── Contents.json │ │ ├── appIcon.imageset/ │ │ │ └── Contents.json │ │ ├── early-afternoon.imageset/ │ │ │ └── Contents.json │ │ ├── early-evening.imageset/ │ │ │ └── Contents.json │ │ ├── early-morning.imageset/ │ │ │ └── Contents.json │ │ ├── evening.imageset/ │ │ │ └── Contents.json │ │ ├── late-afternoon.imageset/ │ │ │ └── Contents.json │ │ ├── late-morning.imageset/ │ │ │ └── Contents.json │ │ ├── telegram-logo.imageset/ │ │ │ └── Contents.json │ │ └── twitter.imageset/ │ │ └── Contents.json │ ├── ContentView.swift │ ├── Data/ │ │ ├── Database.swift │ │ ├── Entry.swift │ │ ├── Fetcher.swift │ │ ├── LaunchAgent.swift │ │ ├── Persistence.swift │ │ ├── Router.swift │ │ ├── SearchCompleter.swift │ │ └── Utilities.swift │ ├── Preview Content/ │ │ └── Preview Assets.xcassets/ │ │ └── Contents.json │ ├── There.entitlements │ ├── ThereApp.swift │ ├── UI/ │ │ ├── Button.swift │ │ ├── Heading.swift │ │ ├── Input.swift │ │ ├── Label.swift │ │ ├── LocalImageView.swift │ │ ├── Titlebar.swift │ │ └── TransparentBackgroundView.swift │ ├── Views/ │ │ ├── ButtomBar/ │ │ │ ├── AddButton.swift │ │ │ ├── BottomBarView.swift │ │ │ └── SettingsButton.swift │ │ ├── CLAuthorizationStatus+Description.swift │ │ ├── EmptyTimezoneView.swift │ │ ├── EntryUI/ │ │ │ ├── EntryIcon.swift │ │ │ └── EntryRow.swift │ │ ├── MainView.swift │ │ ├── Onboarding/ │ │ │ ├── InitialView.swift │ │ │ ├── LeftPanel.swift │ │ │ └── RightPanel.swift │ │ └── Timezone/ │ │ ├── AddTimezone + Components.swift │ │ ├── AddTimezone + Functions.swift │ │ ├── AddTimezone.swift │ │ ├── EditTimeZone + Functions.swift │ │ ├── EditTimeZoneView.swift │ │ ├── FormSection.swift │ │ ├── IconSection.swift │ │ └── NotFoundView.swift │ └── pm.there.There.LaunchAgent.plist ├── ThereTests/ │ └── ThereTests.swift ├── ThereUITests/ │ ├── ThereUITests.swift │ └── ThereUITestsLaunchTests.swift └── readme.md ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ # Xcode .DS_Store xcuserdata/ *.xcodeproj/* !*.xcodeproj/project.pbxproj !*.xcodeproj/xcshareddata/ !*.xcodeproj/project.xcworkspace/ !*.xcworkspace/contents.xcworkspacedata /*.gcno **/xcshareddata/WorkspaceSettings.xcsettings # Swift Package Manager .build/ Packages/ Package.pins Package.resolved *.xcodeproj # CocoaPods Pods/ # Carthage Carthage/Build/ # fastlane fastlane/report.xml fastlane/Preview.html fastlane/screenshots/**/*.png fastlane/test_output # Code Injection injectionforxcode/ # User-specific files *.swp *~ .vscode/ *.moved-aside # macOS specific .AppleDouble .LSOverride Icon ._* .Spotlight-V100 .Trashes # Build products build/ DerivedData/ *.hmap *.ipa *.dSYM.zip *.dSYM # Playgrounds timeline.xctimeline playground.xcworkspace # Swift Package Manager .swiftpm # App packaging *.app *.pkg *.dmg # Crash logs *.crash *.ips # Other *.log *.sqlite ================================================ FILE: LICENSE ================================================ ### MIT License ``` MIT License Copyright (c) 2024 Dena Sohrabi Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 2. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` ================================================ FILE: There/Assets.xcassets/AccentColor.colorset/Contents.json ================================================ { "colors" : [ { "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: There/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "images" : [ { "filename" : "icon-166.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, { "filename" : "icon-1662.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, { "filename" : "icon-3222222.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, { "filename" : "icon-322e22ee2.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, { "filename" : "e1e11221ee21.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, { "filename" : "icon-256feefef.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, { "filename" : "ddwdwdwdw 2.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, { "filename" : "dwqqdwdwqdqwdqw.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" }, { "filename" : "dwqqdwdwqdqwdqw 1.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, { "filename" : "dqdwqdwqdwq.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: There/Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: There/Assets.xcassets/Earth.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "scale" : "1x" }, { "filename" : "Earch&Sun.png", "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: There/Assets.xcassets/Logo.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "scale" : "1x" }, { "filename" : "ThereIcon.png", "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: There/Assets.xcassets/Night.imageset/Contents.json ================================================ { "images" : [ { "filename" : "Night.png", "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: There/Assets.xcassets/appIcon.imageset/Contents.json ================================================ { "images" : [ { "filename" : "iconTemplate.png", "idiom" : "universal", "scale" : "1x" }, { "filename" : "iconTemplate@2x.png", "idiom" : "universal", "scale" : "2x" }, { "filename" : "iconTemplate@3x.png", "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 }, "properties" : { "template-rendering-intent" : "template" } } ================================================ FILE: There/Assets.xcassets/early-afternoon.imageset/Contents.json ================================================ { "images" : [ { "filename" : "Early afternoon.png", "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: There/Assets.xcassets/early-evening.imageset/Contents.json ================================================ { "images" : [ { "filename" : "Early Eavning.png", "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: There/Assets.xcassets/early-morning.imageset/Contents.json ================================================ { "images" : [ { "filename" : "Early Morning.png", "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: There/Assets.xcassets/evening.imageset/Contents.json ================================================ { "images" : [ { "filename" : "Eavning.png", "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: There/Assets.xcassets/late-afternoon.imageset/Contents.json ================================================ { "images" : [ { "filename" : "Late Afternoon.png", "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: There/Assets.xcassets/late-morning.imageset/Contents.json ================================================ { "images" : [ { "filename" : "Late Morning.png", "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: There/Assets.xcassets/telegram-logo.imageset/Contents.json ================================================ { "images" : [ { "filename" : "Logo.png", "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: There/Assets.xcassets/twitter.imageset/Contents.json ================================================ { "images" : [ { "filename" : "twitter.png", "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: There/ContentView.swift ================================================ import SwiftUI struct ContentView: View { @EnvironmentObject var router: Router var body: some View { switch router.activeRoute { case .mainView: MainView() case .addTimezone: AddTimezone() .transition(.asymmetric(insertion: .push(from: .trailing), removal: .push(from: .leading))) case let .editTimeZone(entryId): EditTimeZoneView(entryId: entryId) .transition(.asymmetric(insertion: .push(from: .trailing), removal: .push(from: .leading))) } } } #Preview { ContentView() .frame(width: 300, height: 400) } ================================================ FILE: There/Data/Database.swift ================================================ import Foundation import GRDB import os.log /// /// You create an `AppDatabase` with a connection to an SQLite database /// (see ). /// /// Create those connections with a configuration returned from /// `AppDatabase/makeConfiguration(_:)`. /// /// For example: /// /// ```swift /// // Create an in-memory AppDatabase /// let config = AppDatabase.makeConfiguration() /// let dbQueue = try DatabaseQueue(configuration: config) /// let appDatabase = try AppDatabase(dbQueue) /// ``` struct AppDatabase { /// Creates an `AppDatabase`, and makes sure the database schema /// is ready. /// /// - important: Create the `DatabaseWriter` with a configuration /// returned by ``makeConfiguration(_:)``. init(_ dbWriter: any DatabaseWriter) throws { self.dbWriter = dbWriter try migrator.migrate(dbWriter) } /// Provides access to the database. /// /// Application can use a `DatabasePool`, while SwiftUI previews and tests /// can use a fast in-memory `DatabaseQueue`. /// /// See let dbWriter: any DatabaseWriter } // MARK: - Database Configuration extension AppDatabase { private static let sqlLogger = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "SQL") /// Returns a database configuration suited for `PlayerRepository`. /// /// SQL statements are logged if the `SQL_TRACE` environment variable /// is set. /// /// - parameter base: A base configuration. public static func makeConfiguration(_ base: Configuration = Configuration()) -> Configuration { var config = base // An opportunity to add required custom SQL functions or // collations, if needed: // config.prepareDatabase { db in // db.add(function: ...) // } // Log SQL statements if the `SQL_TRACE` environment variable is set. // See if ProcessInfo.processInfo.environment["SQL_TRACE"] != nil { config.prepareDatabase { db in db.trace { // It's ok to log statements publicly. Sensitive // information (statement arguments) are not logged // unless config.publicStatementArguments is set // (see below). os_log("%{public}@", log: sqlLogger, type: .debug, String(describing: $0)) } } } #if DEBUG // Protect sensitive information by enabling verbose debugging in // DEBUG builds only. // See config.publicStatementArguments = true #endif return config } } // MARK: - Database Migrations extension AppDatabase { /// The DatabaseMigrator that defines the database schema. /// /// See private var migrator: DatabaseMigrator { var migrator = DatabaseMigrator() #if DEBUG // Speed up development by nuking the database when migrations change // See migrator.eraseDatabaseOnSchemaChange = true #endif // Migrations for future application versions will be inserted here: migrator.registerMigration("add entry") { db in try db.create(table: Entry.databaseTableName) { t in t.primaryKey("id", .integer).notNull() t.column(Entry.Columns.type.rawValue, .text).notNull() t.column(Entry.Columns.name.rawValue, .text).notNull() t.column(Entry.Columns.city.rawValue, .text).notNull() t.column(Entry.Columns.timezoneIdentifier.rawValue, .text).notNull() t.column(Entry.Columns.flag.rawValue, .text) t.column(Entry.Columns.photoData.rawValue, .text) } } return migrator } } // MARK: - Database Access: Writes // The write methods execute invariant-preserving database transactions. extension AppDatabase { /// A validation error that prevents some players from being saved into /// the database. enum ValidationError: LocalizedError { case missingName var errorDescription: String? { switch self { case .missingName: return "Please provide a name" } } } } // MARK: - Database Access: Reads // This demo app does not provide any specific reading method, and instead // gives an unrestricted read-only access to the rest of the application. // In your app, you are free to choose another path, and define focused // reading methods. extension AppDatabase { /// Provides a read-only access to the database var reader: DatabaseReader { dbWriter } } ================================================ FILE: There/Data/Entry.swift ================================================ import Foundation import GRDB import SwiftUI enum EntryType: String, Codable { case place case person } enum DayPeriod: String, CaseIterable { case earlyMorning = "early-morning" case lateMorning = "late-morning" case earlyAfternoon = "early-afternoon" case lateAfternoon = "late-afternoon" case earlyEvening = "early-evening" case evening case night } struct Entry: Codable, Equatable, Identifiable, FetchableRecord, PersistableRecord { let id: Int64 var type: EntryType var name: String var city: String var timezoneIdentifier: String var flag: String? var photoData: String? enum Columns: String, ColumnExpression { case id, type, name, city, country, timezoneIdentifier, flag, photoData } init(id: Int64 = Int64.random(in: 1 ... 99999), type: EntryType, name: String, city: String, timezoneIdentifier: String, flag: String? = nil, photoData: String? = nil) { self.id = id self.type = type self.name = name self.city = city self.timezoneIdentifier = timezoneIdentifier self.flag = flag self.photoData = photoData } // MARK: - Codable enum CodingKeys: String, CodingKey { case id, type, name, city, timezoneIdentifier, flag, photoData } var timeDifference: (hours: Int, minutes: Int, dayPeriod: DayPeriod) { let currentDate = Date() let calendar = Calendar.current // Get the current user's time zone let localTimeZone = TimeZone.current // Get the entry's time zone guard let entryTimeZone = TimeZone(identifier: timezoneIdentifier) else { return (0, 0, .night) } // Calculate the time difference let differenceInSeconds = entryTimeZone.secondsFromGMT(for: currentDate) - localTimeZone.secondsFromGMT(for: currentDate) // Convert seconds to hours and minutes let hours = differenceInSeconds / 3600 let minutes = (differenceInSeconds % 3600) / 60 // Calculate the time in the entry's time zone var entryDateComponents = calendar.dateComponents(in: entryTimeZone, from: currentDate) let entryHour = entryDateComponents.hour ?? 0 let dayPeriod: DayPeriod switch entryHour { case 5 ..< 8: dayPeriod = .earlyMorning case 8 ..< 12: dayPeriod = .lateMorning case 12 ..< 15: dayPeriod = .earlyAfternoon case 15 ..< 17: dayPeriod = .lateAfternoon case 17 ..< 19: dayPeriod = .earlyEvening case 19 ..< 22: dayPeriod = .evening default: dayPeriod = .night } return (hours, minutes, dayPeriod) } var timeIcon: String { return timeDifference.dayPeriod.rawValue } } ================================================ FILE: There/Data/Fetcher.swift ================================================ import Combine import Foundation import GRDB enum SortOrder { case timeAscending case timeDescending } class Fetcher: ObservableObject { @Published var entries: [Entry] = [] @Published var getEntries: AnyCancellable? var database: AppDatabase = .shared init() { getEntries = ValueObservation.tracking { db in try Entry.fetchAll(db) } .publisher(in: database.dbWriter, scheduling: .immediate) .sink( receiveCompletion: { _ in /* ignore error */ }, receiveValue: { [weak self] entries in self?.entries = entries } ) } func sortEntries(by order: SortOrder) { switch order { case .timeAscending: entries.sort { $0.timeDifference.hours < $1.timeDifference.hours } case .timeDescending: entries.sort { $0.timeDifference.hours > $1.timeDifference.hours } } } } ================================================ FILE: There/Data/LaunchAgent.swift ================================================ import Foundation import ServiceManagement func installLaunchAgent() { let fileManager = FileManager.default guard let libraryDirectory = fileManager.urls(for: .libraryDirectory, in: .userDomainMask).first else { print("Unable to find user's Library directory") return } let launchAgentsDirectory = libraryDirectory.appendingPathComponent("LaunchAgents") let plistName = "pm.there.There.LaunchAgent.plist" let plistPath = launchAgentsDirectory.appendingPathComponent(plistName) // Create LaunchAgents directory if it doesn't exist if !fileManager.fileExists(atPath: launchAgentsDirectory.path) { do { try fileManager.createDirectory(at: launchAgentsDirectory, withIntermediateDirectories: true, attributes: nil) } catch { print("Error creating LaunchAgents directory: \(error)") return } } // Create the plist content let plistContent = """ Label pm.there.There.LaunchAgent ProgramArguments /Applications/There.app/Contents/MacOS/There RunAtLoad KeepAlive LimitLoadToSessionType Aqua """ do { // Write the plist content to the file try plistContent.write(to: plistPath, atomically: true, encoding: .utf8) print("Launch Agent plist created successfully") // Set the correct permissions try fileManager.setAttributes([.posixPermissions: 0o644], ofItemAtPath: plistPath.path) // Load the launch agent try Process.run(URL(fileURLWithPath: "/bin/launchctl"), arguments: ["load", plistPath.path]) print("Launch Agent loaded successfully") } catch { print("Error installing or loading Launch Agent: \(error)") } // For macOS 13 and later, also register using SMAppService if #available(macOS 13.0, *) { do { try SMAppService.mainApp.register() print("App registered as login item using SMAppService") } catch { print("Failed to register app as login item using SMAppService: \(error)") } } } func uninstallLaunchAgent() { let fileManager = FileManager.default guard let libraryDirectory = fileManager.urls(for: .libraryDirectory, in: .userDomainMask).first else { print("Unable to find user's Library directory") return } let launchAgentsDirectory = libraryDirectory.appendingPathComponent("LaunchAgents") let plistName = "pm.there.There.LaunchAgent.plist" let plistPath = launchAgentsDirectory.appendingPathComponent(plistName) do { // Unload the launch agent try Process.run(URL(fileURLWithPath: "/bin/launchctl"), arguments: ["unload", plistPath.path]) print("Launch Agent unloaded successfully") // Remove the plist file try fileManager.removeItem(at: plistPath) print("Launch Agent plist removed successfully") } catch { print("Error uninstalling Launch Agent: \(error)") } // For macOS 13 and later, also unregister using SMAppService if #available(macOS 13.0, *) { do { try SMAppService.mainApp.unregister() print("App unregistered as login item using SMAppService") } catch { print("Failed to unregister app as login item using SMAppService: \(error)") } } } ================================================ FILE: There/Data/Persistence.swift ================================================ import Foundation import GRDB extension AppDatabase { /// The database for the application static let shared = makeShared() private static func makeShared() -> AppDatabase { do { // Apply recommendations from // // // Create the "Application Support/Database" directory if needed let fileManager = FileManager.default let appSupportURL = try fileManager.url( for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) let directoryURL = appSupportURL.appendingPathComponent("Database", isDirectory: true) // Support for tests: delete the database if requested if CommandLine.arguments.contains("-reset") { try? fileManager.removeItem(at: directoryURL) } // Create the database folder if needed try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) // Open or create the database let databaseURL = directoryURL.appendingPathComponent("db.sqlite") NSLog("Database stored at \(databaseURL.path)") let dbPool = try DatabasePool( path: databaseURL.path, // Use default AppDatabase configuration configuration: AppDatabase.makeConfiguration()) // Create the AppDatabase let appDatabase = try AppDatabase(dbPool) return appDatabase } catch { // Replace this implementation with code to handle the error appropriately. // fatalError() causes the application to generate a crash log and terminate. // // Typical reasons for an error here include: // * The parent directory cannot be created, or disallows writing. // * The database is not accessible, due to permissions or data protection when the device is locked. // * The device is out of space. // * The database could not be migrated to its latest schema version. // Check the error message to determine what the actual problem was. fatalError("Unresolved error \(error)") } } /// Creates an empty database for SwiftUI previews static func empty() -> AppDatabase { // Connect to an in-memory database // See https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databaseconnections let dbQueue = try! DatabaseQueue(configuration: AppDatabase.makeConfiguration()) return try! AppDatabase(dbQueue) } /// Creates a database full of random players for SwiftUI previews static func random() -> AppDatabase { let appDatabase = empty() return appDatabase } } ================================================ FILE: There/Data/Router.swift ================================================ import SwiftUI enum Route { case addTimezone case mainView case editTimeZone(entryId: Int64?) } class Router: ObservableObject { @Published var activeRoute: Route = .mainView func setActiveRoute(to route: Route) { withAnimation(.default) { activeRoute = route } } func cleanActiveRoute() { withAnimation(.default) { activeRoute = .mainView } } } ================================================ FILE: There/Data/SearchCompleter.swift ================================================ import Foundation import MapKit class TimeZoneSearchCompiler: NSObject { private var commonAbbreviations: [String: (fullName: String, identifier: String)] private var utcOffsets: [String: String] private var currentCompletion: (([TimeZoneSearchResult]) -> Void)? override init() { // Initialize common abbreviations commonAbbreviations = [ // North America "EST": ("Eastern Standard Time", "America/New_York"), "EDT": ("Eastern Daylight Time", "America/New_York"), "CST": ("Central Standard Time", "America/Chicago"), "CDT": ("Central Daylight Time", "America/Chicago"), "MST": ("Mountain Standard Time", "America/Denver"), "MDT": ("Mountain Daylight Time", "America/Denver"), "PST": ("Pacific Standard Time", "America/Los_Angeles"), "PDT": ("Pacific Daylight Time", "America/Los_Angeles"), "AKST": ("Alaska Standard Time", "America/Anchorage"), "AKDT": ("Alaska Daylight Time", "America/Anchorage"), "HST": ("Hawaii Standard Time", "Pacific/Honolulu"), // Europe "GMT": ("Greenwich Mean Time", "Etc/GMT"), "BST": ("British Summer Time", "Europe/London"), "CET": ("Central European Time", "Europe/Paris"), "CEST": ("Central European Summer Time", "Europe/Paris"), // Asia "IST": ("India Standard Time", "Asia/Kolkata"), "JST": ("Japan Standard Time", "Asia/Tokyo"), // Australia "AEST": ("Australian Eastern Standard Time", "Australia/Sydney"), "AEDT": ("Australian Eastern Daylight Time", "Australia/Sydney"), // Coordinated Universal Time "UTC": ("Coordinated Universal Time", "Etc/UTC"), ] // Initialize UTC offsets utcOffsets = [ "UTC+0": "Etc/GMT", "UTC-1": "Etc/GMT+1", "UTC-2": "Etc/GMT+2", "UTC-3": "Etc/GMT+3", "UTC-4": "Etc/GMT+4", "UTC-5": "Etc/GMT+5", "UTC-6": "Etc/GMT+6", "UTC-7": "Etc/GMT+7", "UTC-8": "Etc/GMT+8", "UTC-9": "Etc/GMT+9", "UTC-10": "Etc/GMT+10", "UTC-11": "Etc/GMT+11", "UTC-12": "Etc/GMT+12", "UTC+1": "Etc/GMT-1", "UTC+2": "Etc/GMT-2", "UTC+3": "Etc/GMT-3", "UTC+4": "Etc/GMT-4", "UTC+5": "Etc/GMT-5", "UTC+5:30": "Asia/Kolkata", "UTC+6": "Etc/GMT-6", "UTC+7": "Etc/GMT-7", "UTC+8": "Etc/GMT-8", "UTC+9": "Etc/GMT-9", "UTC+10": "Etc/GMT-10", "UTC+11": "Etc/GMT-11", "UTC+12": "Etc/GMT-12", "UTC+13": "Pacific/Apia", "UTC+14": "Pacific/Kiritimati", ] super.init() } func search(query: String, completion: @escaping ([TimeZoneSearchResult]) -> Void) { currentCompletion = completion let results = searchAbbreviations(query: query) + searchUTCOffsets(query: query) if !results.isEmpty { completion(results) return } let request = MKLocalSearch.Request() request.naturalLanguageQuery = query request.resultTypes = .address let search = MKLocalSearch(request: request) search.start { [weak self] response, _ in guard let self = self, let response = response else { completion([]) return } let results = self.processResults(response.mapItems) DispatchQueue.main.async { completion(results) } } } private func processResults(_ mapItems: [MKMapItem]) -> [TimeZoneSearchResult] { return mapItems.compactMap { item in // guard let placemark = item.placemark else { return nil } let placemark = item.placemark let city = placemark.locality ?? placemark.name ?? "" let country = placemark.country ?? "" // placemark.administrativeArea, let subtitle = [country].compactMap { $0 }.joined(separator: ", ") print("placemark.timeZone \(city) \(placemark)") return TimeZoneSearchResult( title: city, subtitle: subtitle, identifier: placemark.timeZone?.identifier, type: .city, region: placemark.region, coordinate: placemark.location ) } } private func searchAbbreviations(query: String) -> [TimeZoneSearchResult] { return commonAbbreviations .filter { $0.key.lowercased().contains(query.lowercased()) } .map { TimeZoneSearchResult(title: $0.key, subtitle: $0.value.fullName, identifier: $0.value.identifier, type: .abbreviation, region: nil, coordinate: nil) } } private func searchUTCOffsets(query: String) -> [TimeZoneSearchResult] { return utcOffsets .filter { $0.key.lowercased().contains(query.lowercased()) } .map { TimeZoneSearchResult(title: $0.key, subtitle: "Coordinated Universal Time Offset", identifier: $0.value, type: .utcOffset, region: nil, coordinate: nil) } } } struct TimeZoneSearchResult: Identifiable, Equatable { static func ==(lhs: TimeZoneSearchResult, rhs: TimeZoneSearchResult) -> Bool { lhs.id == rhs.id && lhs.title == rhs.title && lhs.subtitle == rhs.subtitle && lhs.identifier == rhs.identifier && lhs.type == rhs.type && lhs.region == rhs.region } let id = UUID() let title: String let subtitle: String let identifier: String? let type: TimeZoneSearchResultType let region: CLRegion? let coordinate: CLLocation? func getTimeZone() async -> TimeZone? { switch type { case .city: print("title \(title) coordinate \(coordinate) region \(region)") if let coordinate = coordinate { return await TimeZone.timeZone(for: coordinate) } if let region = region as? CLCircularRegion { let location = CLLocation(latitude: region.center.latitude, longitude: region.center.longitude) return await TimeZone.timeZone(for: location) } return nil case .abbreviation, .utcOffset: return identifier.flatMap { TimeZone(identifier: $0) } } } } enum TimeZoneSearchResultType { case city case abbreviation case utcOffset } extension TimeZone { static func timeZone(for location: CLLocation) async -> TimeZone? { let geocoder = CLGeocoder() do { let placemarks = try await geocoder.reverseGeocodeLocation(location) print("\(placemarks)") return placemarks.first?.timeZone } catch { print("Geocoding error: \(error.localizedDescription)") return nil } } } class SearchCompleter: ObservableObject { @Published var results: [TimeZoneSearchResult] = [] @Published var queryFragment: String = "" { didSet { if queryFragment.isEmpty { results = defaultResults } else { updateResults(for: queryFragment) } } } private let timeZoneSearchCompiler: TimeZoneSearchCompiler private var defaultResults: [TimeZoneSearchResult] = [] init() { timeZoneSearchCompiler = TimeZoneSearchCompiler() setupDefaultResults() } private func setupDefaultResults() { // All timezones and locations for identifier in TimeZone.knownTimeZoneIdentifiers { let components = identifier.split(separator: "/") if components.count >= 2 { let location = String(components.last!).replacingOccurrences(of: "_", with: " ") let region = String(components.first!) defaultResults.append(TimeZoneSearchResult(title: location, subtitle: region, identifier: identifier, type: .city, region: nil, coordinate: nil)) } } // Add UTC offsets for offset in -12 ... 14 { let sign = offset >= 0 ? "+" : "" let title = "UTC\(sign)\(offset)" let identifier = "Etc/GMT\(offset == 0 ? "" : (offset > 0 ? "-" : "+") + "\(abs(offset))")" defaultResults.append(TimeZoneSearchResult(title: title, subtitle: "Coordinated Universal Time Offset", identifier: identifier, type: .utcOffset, region: nil, coordinate: nil)) } results = defaultResults } private var latestSearch = "" private func updateResults(for query: String) { latestSearch = query if query.isEmpty { results = defaultResults } else { timeZoneSearchCompiler.search(query: query) { [weak self] searchResults in DispatchQueue.main.async { if self?.latestSearch == query { self?.results = searchResults } else { // discard } } } } } } ================================================ FILE: There/Data/Utilities.swift ================================================ import Foundation import SwiftUI class Utils { public static var shared = Utils() func selectPhoto() -> NSImage? { let openPanel = NSOpenPanel() openPanel.allowedContentTypes = [.jpeg, .png] openPanel.allowsMultipleSelection = false openPanel.prompt = "Select Image" if openPanel.runModal() == .OK, let url = openPanel.url { return NSImage(contentsOf: url) } else { return nil } } /// Converts a two-letter country code to its corresponding emoji flag. /// /// - Parameter countryCode: ISO 3166-1 alpha-2 country code. /// - Returns: Emoji representation of the country's flag. func getCountryEmoji(for countryCode: String) -> String { // Unicode offset for Regional Indicator Symbols let base: UInt32 = 127397 // Convert each letter to its corresponding Regional Indicator Symbol return countryCode.uppercased().unicodeScalars.map { String(UnicodeScalar(base + $0.value)!) }.joined() } } ================================================ FILE: There/Preview Content/Preview Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: There/There.entitlements ================================================ com.apple.developer.maps com.apple.security.app-sandbox com.apple.security.application-groups group.pm.there.There com.apple.security.files.user-selected.read-only com.apple.security.network.client com.apple.security.network.server com.apple.security.personal-information.location ================================================ FILE: There/ThereApp.swift ================================================ // // ThereApp.swift // There // // Created by Dena Sohrabi on 9/2/24. // import AppKit import MenuBarExtraAccess import PostHog import SwiftUI import UserNotifications @main struct ThereApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @Environment(\.openWindow) var openWindow @ObservedObject var appState = AppState.shared @StateObject var router: Router = Router() var body: some Scene { MenuBarExtra { ContentView() .environment(\.database, .shared) .frame(width: 320) .frame(minHeight: 300) .frame(maxHeight: 600) .background(Color(NSColor.windowBackgroundColor).opacity(0.78).ignoresSafeArea()) .environmentObject(appState) .environmentObject(router) } label: { let image: NSImage = { let ratio = $0.size.height / $0.size.width $0.size.height = 20 $0.size.width = 20 / ratio return $0 }(NSImage(named: "appIcon")!) Image(nsImage: image) .onAppear { if UserDefaults.standard.bool(forKey: "hasCompletedInitialSetup") == false { openWindow(id: "init") } } .foregroundColor(.primary) } .menuBarExtraStyle(.window) .menuBarExtraAccess(isPresented: $appState.menuBarViewIsPresented) .windowResizability(.contentSize) WindowGroup("init", id: "init") { InitialView() .environment(\.database, .shared) .fixedSize() .frame(width: 600, height: 400) .environmentObject(appState) } .windowStyle(.hiddenTitleBar) .defaultSize(width: 600, height: 400) .defaultPosition(.center) .windowResizability(.contentSize) #if MAC_OS_VERSION_15_0 .windowBackgroundDragBehavior(.enabled) #endif Settings { Text("Coming soon...") } #if MAC_OS_VERSION_15_0 .windowStyle(.plain) #endif .defaultSize(width: 600, height: 400) .windowResizability(.automatic) } } extension EnvironmentValues { @Entry var database: AppDatabase = .shared } class AppState: ObservableObject { static let shared = AppState() @Published var menuBarViewIsPresented: Bool = false func presentMenu() { menuBarViewIsPresented = true } func hideMenu() { menuBarViewIsPresented = true } } class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_: Notification) { let POSTHOG_API_KEY = "phc_XZFRnJFd8RVNegex9sLKplgz8KCFxGyLZwxh5usmoig" let POSTHOG_HOST = "https://eu.i.posthog.com" let config = PostHogConfig(apiKey: POSTHOG_API_KEY, host: POSTHOG_HOST) PostHogSDK.shared.setup(config) } } ================================================ FILE: There/UI/Button.swift ================================================ import SwiftUI // PrimaryButton struct PrimaryButton: View { var title: String var action: () -> Void var body: some View { Button(action: action) { Text(title) } .buttonStyle(PrimaryButtonStyle()) } } struct PrimaryButtonStyle: ButtonStyle { let lightBlue = Color(red: 0.24, green: 0.67, blue: 0.91) // #3DAAE8 let darkBlue = Color(red: 0.22, green: 0.60, blue: 0.82) // #3799D1 @State private var hovered: Bool = false func makeBody(configuration: Configuration) -> some View { configuration.label .foregroundColor(.white) .fontWeight(.medium) .frame(width: 200, height: 32) .background( ZStack { RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(hovered ? lightBlue.opacity(0.85) : lightBlue) RoundedRectangle(cornerRadius: 8, style: .continuous) .stroke(hovered ? darkBlue.opacity(0.6) : darkBlue, lineWidth: 3) } ) .cornerRadius(8) .shadow(color: .primary.opacity(0.08), radius: 0.5, x: 0, y: configuration.isPressed ? 0 : (hovered ? 2 : 1)) .scaleEffect(configuration.isPressed ? 0.98 : 1.0) .animation(.easeInOut(duration: 0.1), value: configuration.isPressed) .onHover { hovering in withAnimation { self.hovered = hovering } } } } // SecondaryButton struct SecondaryButton: View { var title: String var action: () -> Void var body: some View { Button(action: action) { Text(title) .foregroundColor(.primary) } .buttonStyle(SecondaryButtonStyle()) } } struct SecondaryButtonStyle: ButtonStyle { @Environment(\.colorScheme) var scheme var white: Color { if scheme == .dark { return Color(.gray).opacity(0.2) } else { return Color(red: 1.0, green: 1.0, blue: 1.0) } } var lightGray: Color { if scheme == .dark { return Color(NSColor.systemGray).opacity(0.2) } else { return Color(red: 0.86, green: 0.86, blue: 0.86) // #DCDCDC } } @State private var hovered: Bool = false func makeBody(configuration: Configuration) -> some View { configuration.label .lineLimit(1) .padding(.horizontal, 6) .foregroundColor(.primary.opacity(0.8)) .fontWeight(.medium) .frame(width: 200, height: 32) .background( ZStack { RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(hovered ? white.opacity(0.85) : white) RoundedRectangle(cornerRadius: 8, style: .continuous) .stroke(hovered ? lightGray.opacity(0.6) : lightGray, lineWidth: 3) } ) .cornerRadius(8) .shadow(color: .primary.opacity(0.08), radius: 0.5, x: 0, y: configuration.isPressed ? 0 : (hovered ? 2 : 1)) .scaleEffect(configuration.isPressed ? 0.98 : 1.0) .animation(.easeInOut(duration: 0.1), value: configuration.isPressed) .onHover { hovering in withAnimation { self.hovered = hovering } } } } // CompactButton struct CompactButton: View { var title: String var action: () -> Void var body: some View { Button(action: action) { Text(title) } .buttonStyle(CompactButtonStyle()) } } struct CompactButtonStyle: ButtonStyle { @Environment(\.colorScheme) var scheme var lightGray: Color { if scheme == .dark { return Color(NSColor.systemGray).opacity(0.2) } else { return Color(red: 0.86, green: 0.86, blue: 0.86) // #DCDCDC } } @State private var hovered: Bool = false func makeBody(configuration: Configuration) -> some View { configuration.label .padding(.horizontal, 8) .foregroundColor(.primary) .fontWeight(.medium) .frame(height: 28) .background(hovered ? (scheme == .dark ? Color(.gray).opacity(0.2) : .white) : (scheme == .dark ? Color(.gray).opacity(0.3) : .white.opacity(0.8))) .cornerRadius(8) .shadow(color: .primary.opacity(0.04), radius: 0.5, x: 0, y: configuration.isPressed ? 0 : (hovered ? 2 : 1)) .scaleEffect(configuration.isPressed ? 0.98 : 1.0) .animation(.easeInOut(duration: 0.1), value: configuration.isPressed) .onHover { hovering in withAnimation { self.hovered = hovering } } } } // CompactPrimaryButton struct CompactPrimaryButton: View { var title: String var action: () -> Void var width: CGFloat = 232 var body: some View { Button(action: action) { Text(title) } .buttonStyle(CompactPrimaryButtonStyle(width: width)) } } struct CompactPrimaryButtonStyle: ButtonStyle { let lightBlue = Color(red: 0.24, green: 0.67, blue: 0.91) // #3DAAE8 let darkBlue = Color(red: 0.22, green: 0.60, blue: 0.82) // #3799D1 @State private var hovered: Bool = false var width: CGFloat func makeBody(configuration: Configuration) -> some View { configuration.label .foregroundColor(.white) .fontWeight(.medium) .frame(width: width, height: 32) .padding(.horizontal, 6) .background( ZStack { RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(hovered ? lightBlue.opacity(0.85) : lightBlue) RoundedRectangle(cornerRadius: 8, style: .continuous) .stroke(hovered ? darkBlue.opacity(0.6) : darkBlue, lineWidth: 3) } ) .cornerRadius(8) .shadow(color: .primary.opacity(0.08), radius: 0.5, x: 0, y: configuration.isPressed ? 0 : (hovered ? 2 : 1)) .scaleEffect(configuration.isPressed ? 0.98 : 1.0) .animation(.easeInOut(duration: 0.1), value: configuration.isPressed) .onHover { hovering in withAnimation { self.hovered = hovering } } } } // Preview struct ButtonPreviews: PreviewProvider { static var previews: some View { VStack(spacing: 20) { PrimaryButton(title: "Primary Button", action: {}) SecondaryButton(title: "Secondary Button", action: {}) CompactButton(title: "Compact Button", action: {}) CompactPrimaryButton(title: "Compact Primary Button", action: {}) } .padding() } } ================================================ FILE: There/UI/Heading.swift ================================================ import SwiftUI struct Heading: View { let title: String var body: some View { Text(title) .font(.title2) .fontWeight(.medium) } } #Preview { Heading(title: "Hello, World!") } ================================================ FILE: There/UI/Input.swift ================================================ import SwiftUI struct AdaptiveColors { static let textFieldBackground = Color(.textBackgroundColor) static let textFieldBorder = Color.secondary static let textColor = Color.primary } struct Input: View { @Binding var text: String var placeholder: String @FocusState private var isFocused: Bool var body: some View { CustomTextInput(text: $text, placeholder: placeholder, isFocused: _isFocused) .frame(width: 200, height: 32) } } struct CompactInput: View { @Binding var text: String var placeholder: String @FocusState private var isFocused: Bool var body: some View { CustomTextInput(text: $text, placeholder: placeholder, isFocused: _isFocused) .frame(height: 32) .padding(.bottom) .scaledToFill() } } struct AutocompleteInput: View { @Binding var text: String let placeholder: String let suggestions: [String] let onCommit: () -> Void @State private var isEditing = false @State private var showSuggestions = false @FocusState private var isFocused: Bool var body: some View { VStack(alignment: .leading) { CustomTextInput(text: $text, placeholder: placeholder, isFocused: _isFocused) .frame(height: 32) .onChange(of: isFocused) { focused in isEditing = focused showSuggestions = focused && !suggestions.isEmpty } .onChange(of: text) { _ in showSuggestions = isEditing && !suggestions.isEmpty } if showSuggestions { ScrollView { LazyVStack(alignment: .leading) { ForEach(suggestions.prefix(5), id: \.self) { suggestion in Text(suggestion) .padding(.vertical, 2) .onTapGesture { text = suggestion showSuggestions = false onCommit() } } } } .frame(maxHeight: 150) .background(AdaptiveColors.textFieldBackground) .cornerRadius(5) .shadow(color: Color.primary.opacity(0.2), radius: 5) } } } } struct CustomTextInput: View { @Binding var text: String var placeholder: String @FocusState var isFocused: Bool var body: some View { ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 8) .fill(AdaptiveColors.textFieldBackground) RoundedRectangle(cornerRadius: 8) .stroke(isFocused ? .blue : AdaptiveColors.textFieldBorder.opacity(0.5), lineWidth: 1) TextField(placeholder, text: $text) .textFieldStyle(.plain) .padding(.horizontal, 6) .foregroundColor(AdaptiveColors.textColor) .focused($isFocused) } .onTapGesture { isFocused = true } } } ================================================ FILE: There/UI/Label.swift ================================================ import SwiftUI struct StyledLabel: View { let title: String var body: some View { Text(title) .font(.caption) .fontWeight(.semibold) .foregroundColor(.secondary) } } #Preview { StyledLabel(title: "Name") } ================================================ FILE: There/UI/LocalImageView.swift ================================================ import SwiftUI struct LocalImageView: View { let imageURL: URL @State private var image: NSImage? @State private var isLoading = true @State private var errorMessage: String? var body: some View { Group { if let image = image { Image(nsImage: image) .resizable() .aspectRatio(contentMode: .fit) } else if isLoading { ProgressView() } else if let errorMessage = errorMessage { Text(errorMessage) .foregroundColor(.red) } } .onAppear(perform: loadImage) } private func loadImage() { isLoading = true DispatchQueue.global(qos: .userInitiated).async { if let imageData = try? Data(contentsOf: imageURL), let loadedImage = NSImage(data: imageData) { DispatchQueue.main.async { self.image = loadedImage self.isLoading = false } } else { DispatchQueue.main.async { self.errorMessage = "Failed to load image" self.isLoading = false } } } } } ================================================ FILE: There/UI/Titlebar.swift ================================================ import SwiftUI struct Titlebar: View { @EnvironmentObject var router: Router @State var hovered: Bool = false var body: some View { HStack(alignment: .center) { Button { router.cleanActiveRoute() } label: { Image(systemName: "chevron.left") .foregroundColor(.secondary) .font(.body) .padding(4) .background(hovered ? Color(NSColor.separatorColor).opacity(0.8) : .clear) .cornerRadius(6) .onHover { hovered in withAnimation { self.hovered = hovered } } } .buttonStyle(.plain) switch router.activeRoute { case .addTimezone: Text("🗺️") .font(.callout) Text("Add Time Zone") .font(.body) .fontWeight(.medium) case .editTimeZone: Text("✍️") .font(.callout) Text("Edit Time Zone") .font(.body) .fontWeight(.medium) case .mainView: EmptyView() } } } } #Preview { Titlebar() .padding() } ================================================ FILE: There/UI/TransparentBackgroundView.swift ================================================ import SwiftUI struct TransparentBackgroundView: NSViewRepresentable { func makeNSView(context: Context) -> NSVisualEffectView { let view = NSVisualEffectView() view.blendingMode = .withinWindow view.state = .active view.material = .popover return view } func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} } #Preview { TransparentBackgroundView() } ================================================ FILE: There/Views/ButtomBar/AddButton.swift ================================================ import SwiftUI struct AddButton: View { @State private var addHovered: Bool = false @EnvironmentObject var appState: AppState @EnvironmentObject var router: Router var body: some View { CompactButton(title: "Add") { router.setActiveRoute(to: .addTimezone) } } } #Preview { AddButton() .padding() } ================================================ FILE: There/Views/ButtomBar/BottomBarView.swift ================================================ import SwiftUI struct BottomBarView: View { @Binding var isAtBottom: Bool @Binding var sortOrder: SortOrder @Binding var showSlider: Bool var body: some View { HStack(spacing: 2) { AddButton() Spacer() Button { withAnimation { showSlider.toggle() } } label: { Image(systemName: "clock.arrow.2.circlepath") .font(.body) } .buttonStyle(SettingsButtonStyle()) .help("Time Slider") SettingsButton(sortOrder: $sortOrder) } .padding(.horizontal, 8) .frame(maxWidth: .infinity) .frame(height: 45) .overlay(alignment: .top) { if isAtBottom { Divider() .padding(.top, -2) } } .animation(.default, value: isAtBottom) } } #Preview { BottomBarView(isAtBottom: .constant(true), sortOrder: .constant(.timeDescending), showSlider: .constant(false)) } ================================================ FILE: There/Views/ButtomBar/SettingsButton.swift ================================================ import SwiftUI struct SettingsButton: View { @State private var settingsHovered: Bool = false @Environment(\.openWindow) var openWindow @Environment(\.openURL) var openURL @EnvironmentObject var appState: AppState @Environment(\.database) var database: AppDatabase @Environment(\.colorScheme) var scheme @AppStorage("launchAtLogin") private var launchAtLogin = false @Binding var sortOrder: SortOrder var backgroundColor: Color { if scheme == .dark { return Color(.gray).opacity(0.2) } else { return .white } } var body: some View { Menu { Toggle("Launch at Login", isOn: $launchAtLogin) .onChange(of: launchAtLogin) { newValue in if newValue { installLaunchAgent() } else { uninstallLaunchAgent() } } Toggle("Ascending order", isOn: Binding( get: { sortOrder == .timeAscending }, set: { newValue in sortOrder = newValue ? .timeAscending : .timeDescending } )) Section("Support") { Button("DM on X") { openURL(URL(string: "https://twitter.com/messages/compose?recipient_id=1434101346110689282")!) } Button("Email Us") { openAppleMailComposer(to: "support@there.pm", subject: "Support Request", body: "Hello, I need assistance with...") } } Section { Button("Open Website") { openURL(URL(string: "https://there.pm")!) } Button("Follow on X") { openURL(URL(string: "https://x.com/ThereHQ")!) } } #if targetEnvironment(simulator) || DEBUG Section("Dev & Debug") { Button("Clear Cache & Data") { UserDefaults.standard.removeObject(forKey: "hasCompletedInitialSetup") do { _ = try database.dbWriter.write { db in try Entry.deleteAll(db) } } catch { print("Can't clear DB \(error)") } } } #endif Divider() Button("Quit") { NSApplication.shared.terminate(nil) } .keyboardShortcut("q", modifiers: .command) } label: { Image(systemName: "gearshape.fill") .font(.body) } .buttonStyle(SettingsButtonStyle()) } func openAppleMailComposer(to recipient: String, subject: String? = nil, body: String? = nil) { let appleMailBundleIdentifier = "com.apple.mail" guard let appleMailURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: appleMailBundleIdentifier) else { print("Apple Mail not found") return } var components = URLComponents() components.scheme = "mailto" components.path = recipient var queryItems = [URLQueryItem]() if let subject = subject { queryItems.append(URLQueryItem(name: "subject", value: subject)) } if let body = body { queryItems.append(URLQueryItem(name: "body", value: body)) } if !queryItems.isEmpty { components.queryItems = queryItems } guard let emailURL = components.url else { print("Invalid email URL") return } let configuration = NSWorkspace.OpenConfiguration() configuration.activates = true NSWorkspace.shared.open([emailURL], withApplicationAt: appleMailURL, configuration: configuration) { _, error in if let error = error { print("Failed to open Apple Mail: \(error.localizedDescription)") } } } } #Preview { SettingsButton(sortOrder: .constant(.timeAscending)) } struct SettingsButtonStyle: ButtonStyle { @Environment(\.colorScheme) var scheme @State private var isHovered = false func makeBody(configuration: Configuration) -> some View { configuration.label .foregroundColor(isHovered ? .primary : .secondary) .frame(height: 28) .padding(.horizontal, 8) .background(isHovered ? backgroundColor : .clear) .cornerRadius(8) .onHover { hovering in withAnimation { isHovered = hovering } } } private var backgroundColor: Color { scheme == .dark ? Color(.gray).opacity(0.2) : .white } } ================================================ FILE: There/Views/CLAuthorizationStatus+Description.swift ================================================ import CoreLocation extension CLAuthorizationStatus: CustomStringConvertible { public var description: String { switch self { case .notDetermined: return "Not Determined" case .restricted: return "Restricted" case .denied: return "Denied" case .authorizedAlways: return "Authorized Always" @unknown default: return "Unknown" } } } ================================================ FILE: There/Views/EmptyTimezoneView.swift ================================================ import SwiftUI struct EmptyTimezoneView: View { var body: some View { VStack { Image("Earth") .resizable() .frame(width: 158, height: 140) .padding(.bottom, 8) .padding(.leading, -43) Text("Hey There!") .font(.title) .fontWeight(.medium) Text("Add your first timezone") .foregroundColor(.secondary) } } } #Preview { EmptyTimezoneView() .frame(width: 320, height: 320) } ================================================ FILE: There/Views/EntryUI/EntryIcon.swift ================================================ import CoreLocation import SwiftUI struct EntryIcon: View { let entry: Entry @Environment(\.colorScheme) var scheme @Environment(\.database) var database @State private var useClockIcon: Bool = false var backgroundColor: Color { if scheme == .dark { return Color(NSColor.darkGray) } else { return Color.white.opacity(0.6) } } var body: some View { Group { if let data = entry.photoData, !data.isEmpty { photoIcon(data: data) } else if let flag = entry.flag { placeIcon } else { defaultIcon } } .overlay(alignment: .bottomTrailing) { timeIcon } .onAppear { if entry.flag == nil || entry.flag!.isEmpty { searchForFlag() } } } private var placeIcon: some View { Circle() .fill(backgroundColor) .frame(width: 45) .overlay { if let flag = entry.flag { Text(flag) .font(.largeTitle) } else { Image(systemName: "clock") .font(.largeTitle) .foregroundColor(.secondary) } } } private func photoIcon(data: String) -> some View { Group { if let url = URL(string: data) { AsyncImage(url: url) { phase in switch phase { case let .success(image): image .resizable() .aspectRatio(contentMode: .fill) .frame(width: 45, height: 45) .clipShape(Circle()) case .failure: defaultIcon case .empty: ProgressView() @unknown default: defaultIcon } } } else { defaultIcon } } } private var defaultIcon: some View { Circle() .fill(backgroundColor) .frame(width: 45) .overlay { if let flag = entry.flag, !flag.isEmpty { Text(flag) .font(.largeTitle) } else { Image(systemName: "clock") .font(.largeTitle) .foregroundColor(.secondary) } } } private var timeIcon: some View { Image(entry.timeIcon) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 14, height: 14) .background( TransparentBackgroundView() .frame(width: 18, height: 18) .cornerRadius(50) ) .padding(.bottom, 4) .padding(.trailing, -3) } func searchForFlag() { let geocoder = CLGeocoder() geocoder.geocodeAddressString(entry.city) { placemarks, _ in if let placemark = placemarks?.first, let timezone = placemark.timeZone { Task { do { try await database.dbWriter.write { db in var entry = try Entry.fetchOne(db, id: entry.id) let countryEmoji = Utils.shared.getCountryEmoji(for: placemark.isoCountryCode ?? "") entry?.flag = countryEmoji.isEmpty ? "🌍" : countryEmoji try entry?.update(db) } } catch { print("Can't find flag \(error)") } } } } } } ================================================ FILE: There/Views/EntryUI/EntryRow.swift ================================================ import Combine import SwiftUI struct EntryRow: View { let entry: Entry @State private var isHovered: Bool = false @Environment(\.colorScheme) var scheme @EnvironmentObject var router: Router @Environment(\.scenePhase) private var scenePhase @State private var currentDate = Date() @State private var timer: Publishers.Autoconnect? @Binding var timeOffset: Double var body: some View { HStack { EntryIcon(entry: entry) VStack(alignment: .leading) { Text(entry.name.isEmpty ? entry.city : entry.name) .font(.title3) .fontWeight(.medium) .lineLimit(1) Text(entry.city) .font(.body) .foregroundColor(.secondary) .lineLimit(1) } .padding(.leading, 6) Spacer() VStack(alignment: .trailing) { HStack(spacing: 0) { Text(formattedTime(timeZoneIdentifier: entry.timezoneIdentifier)) .monospaced() .font(.body) .contentTransition(.numericText()) } Text(formatTimeDifference()) .monospaced() .font(.callout) .foregroundColor(.gray) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 8) .padding(.vertical, 6) .background(isHovered ? scheme == .dark ? Color.white.opacity(0.1) : Color.white.opacity(0.6) : Color.clear) .cornerRadius(8) .onHover { isHovered in withAnimation(.easeInOut(duration: 0.1)) { self.isHovered = isHovered } } .onReceive(timer ?? Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { _ in currentDate = Date() } .onTapGesture { router.setActiveRoute(to: .editTimeZone(entryId: entry.id)) } .onChange(of: scenePhase) { newPhase in updateTimer(for: newPhase) } .onAppear { updateTimer(for: scenePhase) } } private func formattedTime(timeZoneIdentifier: String) -> String { let formatter = DateFormatter() formatter.timeZone = TimeZone(identifier: timeZoneIdentifier) // Get the system's locale let locale = Locale.current // Create a template that includes both 24-hour and 12-hour formats let template = "j:mm" // Generate the best format for the current locale if let formatString = DateFormatter.dateFormat(fromTemplate: template, options: 0, locale: locale) { formatter.dateFormat = formatString } else { // Fallback to a default format if generation fails formatter.timeStyle = .short } let offsetDate = Date().addingTimeInterval(timeOffset * 3600) return formatter.string(from: offsetDate) } private func formatTimeDifference() -> String { let userTimeZone = TimeZone.current let entryTimeZone = TimeZone(identifier: entry.timezoneIdentifier) ?? .current let userDate = currentDate.addingTimeInterval(TimeInterval(userTimeZone.secondsFromGMT())) let entryDate = currentDate.addingTimeInterval(TimeInterval(entryTimeZone.secondsFromGMT())) let difference = Calendar.current.dateComponents([.hour, .minute], from: userDate, to: entryDate) let hours = difference.hour ?? 0 let minutes = difference.minute ?? 0 if hours == 0 && minutes == 0 { return "same time" } let totalHours = Double(hours) + Double(minutes) / 60.0 return String(format: "%+.1f hrs", totalHours) } private func updateTimer(for phase: ScenePhase) { timer?.upstream.connect().cancel() switch phase { case .active: timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() case .inactive, .background: timer = Timer.publish(every: 60, on: .main, in: .common).autoconnect() @unknown default: timer = Timer.publish(every: 60, on: .main, in: .common).autoconnect() } } } ================================================ FILE: There/Views/MainView.swift ================================================ import AppKit import GRDB import PostHog import SwiftUI struct MainView: View { @StateObject private var fetcher = Fetcher() @State private var sortOrder: SortOrder = .timeAscending @State private var sortedEntries: [Entry] = [] @Environment(\.database) var database: AppDatabase @EnvironmentObject var appState: AppState @EnvironmentObject var router: Router @State private var currentDate = Date() @State private var isAtBottom: Bool = false @State private var showSlider: Bool = false @State var timeOffset: Double = 0 var body: some View { VStack(alignment: sortedEntries.isEmpty ? .center : .leading, spacing: 2) { if sortedEntries.isEmpty { Spacer() EmptyTimezoneView() Spacer() } else { ScrollView(.vertical) { LazyVStack(spacing: 0) { ForEach(sortedEntries) { entry in EntryRow(entry: entry, timeOffset: $timeOffset) .contextMenu { Button("Edit") { router.setActiveRoute(to: .editTimeZone(entryId: entry.id)) } Button("Delete", role: .destructive) { deleteEntry(entry) } } .id(entry.id) } .animation(.easeInOut(duration: 0.1), value: sortedEntries.count) Color.clear .frame(height: 2) .onAppear { isAtBottom = false } .onDisappear { isAtBottom = true } } .padding(.horizontal, 6) } .scrollIndicators(.hidden) } if showSlider { EntryTimeSlider(timeOffset: $timeOffset) .onDisappear { withAnimation { timeOffset = 0.0 } } } BottomBarView(isAtBottom: $isAtBottom, sortOrder: $sortOrder, showSlider: $showSlider) } .frame(maxHeight: .infinity) .padding(.top, 6) .onAppear { sortEntries() } .onChange(of: sortOrder) { _ in sortEntries() } .onChange(of: fetcher.entries) { _ in sortEntries() } .task { let id = PostHogSDK.shared.getAnonymousId() PostHogSDK.shared.identify(id, userProperties: ["email": UserDefaults.standard.string(forKey: "userEmail") ?? ""]) } } private func sortEntries() { switch sortOrder { case .timeAscending: sortedEntries = fetcher.entries.sorted { $0.timeDifference.hours < $1.timeDifference.hours } case .timeDescending: sortedEntries = fetcher.entries.sorted { $0.timeDifference.hours > $1.timeDifference.hours } } } private func deleteEntry(_ entry: Entry) { Task { do { try await database.dbWriter.write { db in let fetchedEntry = try Entry.fetchOne(db, id: entry.id) try fetchedEntry?.delete(db) } } catch { print("Can't delete entry \(error)") } } } } #Preview { MainView() .frame(width: 400, height: 400) } struct EntryTimeSlider: View { @Binding var timeOffset: Double @State private var previousValue: Double = 0 @State var currentHour: Double = Date().hour var offset: String { if timeOffset == 1 || timeOffset == -1 { return "\(timeOffset > 0 ? "+" : "") \(timeOffset) hr" } else { return "\(timeOffset > 0 ? "+" : "")\(timeOffset) hrs" } } var body: some View { VStack(spacing: 8) { HStack { Text("\(offset)") .monospaced() .font(.callout) .foregroundColor(.gray) .contentTransition(.numericText()) Spacer() Text(formattedTime()) .monospaced() .font(.body) .fontWeight(.semibold) .contentTransition(.numericText()) } Slider(value: $currentHour, in: 0 ... 23.5, step: 0.5) .onChange(of: currentHour) { newValue in withAnimation { timeOffset = newValue - Date().hour if Int(newValue) != Int(previousValue) { performHapticFeedback() } previousValue = newValue } } } .padding(.vertical, 8) .padding(.horizontal, 16) .background(Color(NSColor.windowBackgroundColor)) } private func formattedTime() -> String { let calendar = Calendar.current let now = Date() let formatter = DateFormatter() // Get the system's locale let locale = Locale.current // Create a template that includes both 24-hour and 12-hour formats let template = "j:mm" // Generate the best format for the current locale if let formatString = DateFormatter.dateFormat(fromTemplate: template, options: 0, locale: locale) { formatter.dateFormat = formatString } else { // Fallback to a default format if generation fails formatter.timeStyle = .short } formatter.locale = locale let offsetDate = Date().addingTimeInterval(timeOffset * 3600) return formatter.string(from: offsetDate) } private func performHapticFeedback() { NSHapticFeedbackManager.defaultPerformer.perform(.levelChange, performanceTime: .default) } } extension Date { var hour: Double { Double(Calendar.current.component(.hour, from: self)) + (Calendar.current.component(.minute, from: self) >= 30 ? 0.5 : 0.0) } } ================================================ FILE: There/Views/Onboarding/InitialView.swift ================================================ // InitialView.swift import PostHog import SwiftUI struct InitialView: View { @Environment(\.database) var database @State private var email: String = "" @EnvironmentObject var appState: AppState var body: some View { HStack(spacing: 80) { LeftPanel() RightPanel(email: $email, saveEmail: saveEmail) } .padding() } func signupForThere(email: String, completion: @escaping (Bool) -> Void) { var hostname: String { #if DEBUG return "http://localhost:8000" #else return "https://headline.inline.chat" #endif } let url = URL(string: "\(hostname)/api/there/signup")! var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") let timeZone = TimeZone.current.identifier let body: [String: Any] = ["email": email, "timeZone": timeZone] request.httpBody = try? JSONSerialization.data(withJSONObject: body) URLSession.shared.dataTask(with: request) { _, response, _ in let success = (response as? HTTPURLResponse)?.statusCode == 200 DispatchQueue.main.async { completion(success) } }.resume() } func saveEmail() { signupForThere(email: email) { success in if success { PostHogSDK.shared.capture("user_signed_up", userProperties: ["email": email], userPropertiesSetOnce: ["date_of_sign_up": Date.now]) print("Signup successful") UserDefaults.standard.set(email, forKey: "userEmail") UserDefaults.standard.set(true, forKey: "hasCompletedInitialSetup") } else { print("Signup failed") UserDefaults.standard.set(true, forKey: "hasCompletedInitialSetup") } } } } #Preview { InitialView() .frame(width: 600, height: 400) } ================================================ FILE: There/Views/Onboarding/LeftPanel.swift ================================================ // LeftPanel.swift import SwiftUI struct LeftPanel: View { var body: some View { VStack(alignment: .leading, spacing: 8) { Image("Logo") Text("Hey There!") .font(.largeTitle) .fontWeight(.bold) DateTimeInfo() } } } struct DateTimeInfo: View { var body: some View { VStack(alignment: .leading, spacing: 8) { HStack { DateTimeLabel(date: Date.now, format: .dateTime.timeZone() , color: .green) DateTimeLabel(date: Date.now, format: .dateTime.hour().minute(), color: .cyan) } HStack { DateTimeLabel(text: TimeZone.current.identifier, color: .yellow) DateTimeLabel(date: Date.now, format: .dateTime.weekday(), color: .pink) } } } } struct DateTimeLabel: View { var date: Date? var text: String? var format: Date.FormatStyle? var color: Color init(date: Date? = nil, text: String? = nil, format: Date.FormatStyle? = nil, color: Color = .green) { self.date = date self.text = text self.format = format self.color = color } var body: some View { Group { if let date = date, let format = format { Text(date, format: format) } else if let text = text { Text(text) } else { Text("Invalid input") } } .monospaced() .padding(4) .background(color.opacity(0.2)) .cornerRadius(8) } } ================================================ FILE: There/Views/Onboarding/RightPanel.swift ================================================ // RightPanel.swift import PostHog import SwiftUI struct RightPanel: View { @Binding var email: String let saveEmail: () -> Void @EnvironmentObject var appState: AppState @Environment(\.presentationMode) var presentationMode var body: some View { VStack(alignment: .leading, spacing: 4) { Text("Please enter your email") .font(.callout) .fontWeight(.medium) .padding(.bottom, 2) Input(text: $email, placeholder: "dena@example.com") PrimaryButton(title: "Continue", action: { saveEmail() appState.presentMenu() presentationMode.wrappedValue.dismiss() }) SecondaryButton(title: "Skip", action: { PostHogSDK.shared.capture("sign_up_skipped", properties: ["date": Date.now]) UserDefaults.standard.setValue(true, forKey: "hasCompletedInitialSetup") appState.presentMenu() presentationMode.wrappedValue.dismiss() }) } } } ================================================ FILE: There/Views/Timezone/AddTimezone + Components.swift ================================================ import AppKit import MapKit import SwiftUI // MARK: - IconView struct IconView: View { @Binding var image: NSImage? @Binding var countryEmoji: String @State private var showPopover = false @State private var isDropTargeted = false @State private var showingXAccountInput = false @State private var showingTGAccountInput = false @State private var username = "" @State private var debounceTask: Task? var body: some View { iconContent .frame(width: 70, height: 70) .onTapGesture { showPopover = true } .popover(isPresented: $showPopover) { popoverContent } } private var iconContent: some View { Group { if let image = image { Image(nsImage: image) .resizable() .scaledToFill() .clipShape(Circle()) } else if !countryEmoji.isEmpty { flagView } else { placeholderView } } } private var flagView: some View { Circle() .fill(.white) .overlay(Text(countryEmoji).font(.largeTitle)) } private var placeholderView: some View { Circle() .fill(.gray.opacity(0.1)) .overlay( Image(systemName: "photo.badge.plus") .font(.title) .foregroundColor(.gray.opacity(0.8)) ) } private var popoverContent: some View { VStack { importButtons if showingXAccountInput { SocialMediaInput(platform: "X", username: $username, image: $image, debounceTask: $debounceTask) .onSubmit { showPopover = false } } else if showingTGAccountInput { SocialMediaInput(platform: "Telegram", username: $username, image: $image, debounceTask: $debounceTask) .onSubmit { showPopover = false } } } .padding() } private var importButtons: some View { HStack(alignment: .center, spacing: 0) { Text("Set from").foregroundColor(.secondary) Button("Telegram") { showingXAccountInput = false showingTGAccountInput = true } .buttonStyle(.link) .padding(.horizontal, 2) Text("/").foregroundColor(.secondary).padding(.horizontal, 2) Button("X") { showingTGAccountInput = false showingXAccountInput = true } .buttonStyle(.link) .padding(.horizontal, 2) Text("/").foregroundColor(.secondary).padding(.horizontal, 2) Button("Finder") { let selectedImage = Utils.shared.selectPhoto() DispatchQueue.main.async { self.image = selectedImage } }.buttonStyle(.link).padding(.horizontal, 2) } } private func handleDrop(providers: [NSItemProvider]) -> Bool { guard let provider = providers.first else { return false } provider.loadObject(ofClass: NSImage.self) { object, _ in if let image = object as? NSImage { DispatchQueue.main.async { self.image = image } } } return true } } struct CitySearchResultRow: View, Equatable { let result: TimeZoneSearchResult var body: some View { HStack { VStack(alignment: .leading) { Text(result.title) Text(result.subtitle ?? "") .font(.caption) .foregroundColor(.secondary) } Spacer() } .frame(maxWidth: .infinity) .contentShape(Rectangle()) } static func == (lhs: CitySearchResultRow, rhs: CitySearchResultRow) -> Bool { lhs.result == rhs.result } } // MARK: - CitySearchResults struct CitySearchResults: View { @ObservedObject var searchCompleter: SearchCompleter @Binding var isShowingPopover: Bool @Binding var selectedCity: String @Binding var selectedTimezone: TimeZone? @Binding var countryEmoji: String @FocusState private var isFocused: Bool @State private var selectedIndex: Int = -1 var body: some View { VStack(spacing: 0) { CustomTextField(text: $searchCompleter.queryFragment, placeholder: "Search for a city or timezone", onKeyDown: handleKeyEvent) .textFieldStyle(.roundedBorder) .padding(.horizontal, 6) .frame(width: 280, height: 32) .background(AdaptiveColors.textFieldBackground) .cornerRadius(8) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(isFocused ? .blue : AdaptiveColors.textFieldBorder.opacity(0.5), lineWidth: 1) ) .focused($isFocused) .foregroundColor(AdaptiveColors.textColor) .padding(.vertical) ScrollViewReader { proxy in List(searchCompleter.results.indices, id: \.self) { index in let result = searchCompleter.results[index] Button(action: { self.selectCity(result) }) { CitySearchResultRow(result: result) } .buttonStyle(.plain) .listRowBackground(selectedIndex == index ? Color.accentColor.opacity(0.1) : Color.clear) .id(index) } .listStyle(PlainListStyle()) .onChange(of: selectedIndex) { newValue in if newValue >= 0 { // This handles all scrolling scenarios, including scrolling to the bottom // when the last item is selected: // 1. It triggers whenever selectedIndex changes. // 2. It scrolls to the newly selected item if it's in the list (index >= 0). // 3. The .center anchor attempts to center the item in the visible area. // 4. For the last item, this effectively scrolls to the bottom of the list. // 5. It also ensures that the selected item is always visible, even if it's // not the last item. proxy.scrollTo(newValue, anchor: .center) } } } } .frame(width: 300, height: 400) } private func handleKeyEvent(_ event: NSEvent) -> Bool { switch event.keyCode { case 126: // Up arrow moveSelection(direction: .up) return true case 125: // Down arrow moveSelection(direction: .down) return true case 36: // Return key if selectedIndex >= 0 && selectedIndex < searchCompleter.results.count { selectCity(searchCompleter.results[selectedIndex]) } return true default: return false } } private func moveSelection(direction: KeyboardNavigationDirection) { let itemCount = searchCompleter.results.count switch direction { case .up: if selectedIndex > 0 { // If not at the top of the list, move up one item selectedIndex -= 1 } else if selectedIndex == 0 { // If at the top of the list, move focus to the search field selectedIndex = -1 } else if selectedIndex == -1 { // If focus is on the search field, move to the bottom of the list selectedIndex = itemCount - 1 } case .down: if selectedIndex == -1 { // If focus is on the search field and there are items, select the first item if itemCount > 0 { selectedIndex = 0 } } else if selectedIndex < itemCount - 1 { // If not at the bottom of the list, move down one item selectedIndex += 1 } else if selectedIndex == itemCount - 1 { // If at the bottom of the list, move focus back to the search field selectedIndex = -1 } case .enter: // Enter key handling is done elsewhere break } } private func selectCity(_ result: TimeZoneSearchResult) { switch result.type { case .city: selectedCity = "\(result.title), \(result.subtitle)" Task { if let timezone = await result.getTimeZone() { await MainActor.run { selectedTimezone = timezone let geocoder = CLGeocoder() geocoder.geocodeAddressString(result.title) { [self] placemarks, _ in if let placemark = placemarks?.first, let timezone = selectedTimezone { countryEmoji = Utils.shared.getCountryEmoji(for: placemark.isoCountryCode ?? "") } } } } else { await MainActor.run { fallbackToGeocoding(for: selectedCity) } } } case .abbreviation: selectedCity = result.title selectedTimezone = result.identifier.flatMap { TimeZone(identifier: $0) } countryEmoji = "" case .utcOffset: selectedCity = result.title selectedTimezone = result.identifier.flatMap { TimeZone(identifier: $0) } countryEmoji = "" } isShowingPopover = false } private func fallbackToGeocoding(for address: String) { let geocoder = CLGeocoder() geocoder.geocodeAddressString(address) { [self] placemarks, _ in if let placemark = placemarks?.first, let timezone = placemark.timeZone { selectedTimezone = timezone countryEmoji = Utils.shared.getCountryEmoji(for: placemark.isoCountryCode ?? "") } } } // private func selectCity(_ result: TimeZoneSearchResult) { // // selectedCity = "\(result.title), \(result.subtitle)" // selectedTimezone = result.getTimeZone() // // if result.type == .city { // let geocoder = CLGeocoder() // geocoder.geocodeAddressString(selectedCity) { placemarks, _ in // if let placemark = placemarks?.first { // countryEmoji = Utils.shared.getCountryEmoji(for: placemark.isoCountryCode ?? "") // } // } // } else { // countryEmoji = "" // } // // isShowingPopover = false // } } struct CustomTextField: NSViewRepresentable { @Binding var text: String var placeholder: String var onKeyDown: (NSEvent) -> Bool func makeNSView(context: Context) -> NSTextField { let textField = NSTextField() textField.placeholderString = placeholder textField.delegate = context.coordinator textField.focusRingType = .none textField.drawsBackground = true textField.backgroundColor = .white textField.isBordered = false textField.textColor = .black return textField } func updateNSView(_ nsView: NSTextField, context: Context) { nsView.stringValue = text } func makeCoordinator() -> Coordinator { Coordinator(self) } class Coordinator: NSObject, NSTextFieldDelegate { var parent: CustomTextField init(_ parent: CustomTextField) { self.parent = parent } func controlTextDidChange(_ obj: Notification) { if let textField = obj.object as? NSTextField { parent.text = textField.stringValue } } func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { if commandSelector == #selector(NSResponder.moveUp(_:)) { return parent.onKeyDown(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: 0, context: nil, characters: "", charactersIgnoringModifiers: "", isARepeat: false, keyCode: 126)!) } else if commandSelector == #selector(NSResponder.moveDown(_:)) { return parent.onKeyDown(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: 0, context: nil, characters: "", charactersIgnoringModifiers: "", isARepeat: false, keyCode: 125)!) } else if commandSelector == #selector(NSResponder.insertNewline(_:)) { return parent.onKeyDown(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: 0, context: nil, characters: "\r", charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: 36)!) } return false } } } enum KeyboardNavigationDirection { case up, down, enter } ================================================ FILE: There/Views/Timezone/AddTimezone + Functions.swift ================================================ import AppKit import CoreLocation import Foundation import MapKit import SwiftUI extension AddTimezone { func searchPlace(_ completion: MKLocalSearchCompletion) { let searchRequest = MKLocalSearch.Request(completion: completion) let search = MKLocalSearch(request: searchRequest) search.start { response, _ in guard let coordinate = response?.mapItems.first?.placemark.coordinate else { return } let location = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude) CLGeocoder().reverseGeocodeLocation(location) { placemarks, _ in DispatchQueue.main.async { if let placemark = placemarks?.first { if let timeZone = placemark.timeZone { self.selectedTimeZone = timeZone } self.countryEmoji = Utils.shared.getCountryEmoji(for: placemark.isoCountryCode ?? "") } } } } } func saveEntry() { let fileName = UUID().uuidString + ".png" let fileURL = getApplicationSupportDirectory().appendingPathComponent(fileName) if let tiffData = image?.tiffRepresentation, let bitmapImage = NSBitmapImageRep(data: tiffData), let pngData = bitmapImage.representation(using: .png, properties: [:]) { do { try pngData.write(to: fileURL) } catch { print("Failed to save image: \(error)") } } do { try database.dbWriter.write { db in let entry = Entry( id: Int64.random(in: 1 ... 99999), type: !countryEmoji.isEmpty && image == nil ? .place : .person, name: name, city: city, timezoneIdentifier: selectedTimeZone?.identifier ?? "", flag: image == nil ? countryEmoji : "", photoData: image != nil ? fileURL.absoluteString : nil ) try entry.save(db) } } catch { print("Failed to save entry \(error)") } router.cleanActiveRoute() resetForm() } private func resetForm() { image = nil name = "" city = "" showingXAccountInput = false showingTGAccountInput = false selectedTimeZone = TimeZone.current isShowingPopover = false countryEmoji = "" selectedTimeZone = nil } private func getApplicationSupportDirectory() -> URL { FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] } } ================================================ FILE: There/Views/Timezone/AddTimezone.swift ================================================ import CoreLocation import MapKit import SwiftUI struct AddTimezone: View { @Environment(\.database) var database @StateObject private var searchCompleter = SearchCompleter() @EnvironmentObject var router: Router @State var image: NSImage? @State var name = "" @State var city = "" @State var selectedTimeZone: TimeZone? = nil @State var isShowingPopover = false @State var countryEmoji = "" @State var showingXAccountInput = false @State var showingTGAccountInput = false var body: some View { VStack(alignment: .center, spacing: 0) { IconSection( image: $image, countryEmoji: $countryEmoji, showingXAccountInput: $showingXAccountInput, showingTGAccountInput: $showingTGAccountInput ) FormSection( name: $name, city: $city, selectedTimeZone: $selectedTimeZone, isShowingPopover: $isShowingPopover, searchCompleter: searchCompleter, countryEmoji: $countryEmoji, image: $image, showingTGAccountInput: $showingTGAccountInput, showingXAccountInput: $showingXAccountInput, saveEntry: saveEntry ) } .frame(maxWidth: .infinity, maxHeight: .infinity) .overlay(alignment: .topLeading) { Titlebar() .padding(6) } } } #Preview { AddTimezone() .frame(width: 300, height: 400) } ================================================ FILE: There/Views/Timezone/EditTimeZone + Functions.swift ================================================ import AppKit import CoreLocation import Foundation import MapKit import SwiftUI extension EditTimeZoneView { func saveEntry() { let fileName = UUID().uuidString + ".png" let fileURL = getApplicationSupportDirectory().appendingPathComponent(fileName) if let tiffData = image?.tiffRepresentation, let bitmapImage = NSBitmapImageRep(data: tiffData), let pngData = bitmapImage.representation(using: .png, properties: [:]) { do { try pngData.write(to: fileURL) } catch { print("Failed to save image: \(error)") } } do { try database.dbWriter.write { db in if let entry = entry { let entry = Entry( id: entry.id, type: !countryEmoji.isEmpty && image == nil ? .place : .person, name: name, city: city, timezoneIdentifier: selectedTimeZone?.identifier ?? "", flag: image == nil ? countryEmoji : "", photoData: fileURL.absoluteString ) try entry.save(db) } } } catch { print("Failed to save entry \(error)") } router.cleanActiveRoute() } private func getApplicationSupportDirectory() -> URL { FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] } } ================================================ FILE: There/Views/Timezone/EditTimeZoneView.swift ================================================ import CoreLocation import MapKit import SwiftUI struct EditTimeZoneView: View { var entryId: Int64? @Environment(\.database) var database @StateObject private var searchCompleter = SearchCompleter() @EnvironmentObject var router: Router @State var entry: Entry? @State var image: NSImage? @State var name = "" @State var city = "" @State var selectedTimeZone: TimeZone? @State var isShowingPopover = false @State var countryEmoji = "" @State var showingXAccountInput = false @State var showingTGAccountInput = false @State var isLoading = true @State var errorMessage: String? var body: some View { VStack(alignment: .center, spacing: 0) { if isLoading { ProgressView() } else if let entry = entry { IconSection( image: $image, countryEmoji: $countryEmoji, showingXAccountInput: $showingXAccountInput, showingTGAccountInput: $showingTGAccountInput ) FormSection( name: $name, city: $city, selectedTimeZone: $selectedTimeZone, isShowingPopover: $isShowingPopover, searchCompleter: searchCompleter, countryEmoji: $countryEmoji, image: $image, showingTGAccountInput: $showingTGAccountInput, showingXAccountInput: $showingXAccountInput, isEditing: true, saveEntry: saveEntry ) } else { NotFoundView() } if let errorMessage = errorMessage { Text(errorMessage) .foregroundColor(.red) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .overlay(alignment: .topLeading) { Titlebar() .padding(6) } .task { if entry == nil { await loadEntry() } } } private func loadEntry() async { isLoading = true do { try await database.reader.read { db in if let id = entryId { let fetchedEntry = try Entry.fetchOne(db, id: id) self.entry = fetchedEntry if let entry = fetchedEntry { self.name = entry.name self.city = entry.city self.selectedTimeZone = TimeZone(identifier: entry.timezoneIdentifier) self.countryEmoji = entry.flag ?? "" if entry.photoData != nil, let imageURL = URL(string: entry.photoData!) { if let imageData = try? Data(contentsOf: imageURL) { self.image = NSImage(data: imageData) } else { print("Failed to load image data from URL: \(imageURL)") } } else { self.image = nil } } } } } catch { errorMessage = "Failed to load entry: \(error.localizedDescription)" } isLoading = false } } #Preview { EditTimeZoneView(entryId: 1712) .frame(width: 300, height: 400) .environment(\.database, .shared) } ================================================ FILE: There/Views/Timezone/FormSection.swift ================================================ import PostHog import SwiftUI import UserNotifications struct FormSection: View { @Binding var name: String @Binding var city: String @Binding var selectedTimeZone: TimeZone? @Binding var isShowingPopover: Bool @StateObject var searchCompleter: SearchCompleter @Binding var countryEmoji: String @Binding var image: NSImage? @Binding var showingTGAccountInput: Bool @Binding var showingXAccountInput: Bool @State var showError: Bool = false @State private var username = "" @State private var debounceTask: Task? var isEditing: Bool = false let saveEntry: () -> Void var body: some View { VStack(alignment: .leading, spacing: 2) { StyledLabel(title: "Name") Input(text: $name, placeholder: "eg. Dena or London Office") .padding(.bottom, 6) .onSubmit { if !city.isEmpty { PostHogSDK.shared.capture("timezone_added") saveEntry() } else { withAnimation(.easeIn(duration: 0.1)) { showError = true } } } if !city.isEmpty { SecondaryButton(title: city) { withAnimation(.easeOut(duration: 0.1)) { showError = false } isShowingPopover = true } .popover(isPresented: $isShowingPopover) { CitySearchResults( searchCompleter: searchCompleter, isShowingPopover: $isShowingPopover, selectedCity: $city, selectedTimezone: $selectedTimeZone, countryEmoji: $countryEmoji ) } } else { SecondaryButton(title: "Set location / timezone") { withAnimation(.easeOut(duration: 0.1)) { showError = false } isShowingPopover = true } .popover(isPresented: $isShowingPopover) { CitySearchResults( searchCompleter: searchCompleter, isShowingPopover: $isShowingPopover, selectedCity: $city, selectedTimezone: $selectedTimeZone, countryEmoji: $countryEmoji ) } } if showError { Text("please select a Location") .font(.caption) .foregroundColor(.red) .fontWeight(.medium) .transition(.opacity) } PrimaryButton(title: isEditing ? "Update" : "Add", action: { if !city.isEmpty { saveEntry() PostHogSDK.shared.capture("timezone_added") } else { withAnimation(.easeIn(duration: 0.1)) { showError = true } } }) .disabled(city.isEmpty) .opacity(city.isEmpty ? 0.6 : 1) .padding(.top, 8) } } } ================================================ FILE: There/Views/Timezone/IconSection.swift ================================================ import SwiftUI struct IconSection: View { @Binding var image: NSImage? @Binding var countryEmoji: String @Binding var showingXAccountInput: Bool @Binding var showingTGAccountInput: Bool @State private var xHovered = false @State private var tgHovered = false var body: some View { VStack { IconView( image: $image, countryEmoji: $countryEmoji ) .padding(.bottom, 6) } } } struct SocialMediaButton: View { let imageName: String @Binding var isHovered: Bool let action: () -> Void var body: some View { Button(action: action) { Image(imageName) .resizable() .frame(width: 18, height: 18) .clipShape(Circle()) } .buttonStyle(PlainButtonStyle()) .scaleEffect(isHovered ? 1.1 : 1) .shadow(color: isHovered ? .black.opacity(0.2) : .clear, radius: 4, x: 0, y: 4) .onHover { hovering in withAnimation { isHovered = hovering } } } } struct SocialMediaInput: View { let platform: String @Binding var username: String @Binding var image: NSImage? @Binding var debounceTask: Task? var body: some View { VStack(alignment: .leading) { StyledLabel(title: platform == "X" ? "Enter an \(platform) username" : "Enter a \(platform) username") .padding(.top, 8) Input(text: $username, placeholder: "eg. dena_sohrabi") .onChange(of: username) { value in debounceTask?.cancel() if !value.isEmpty { debounceTask = Task { try? await Task.sleep(for: .milliseconds(800)) if !Task.isCancelled { do { let imageUrl = "https://unavatar.io/\(platform.lowercased())/\(value.lowercased())" let fetchedImage = try await simpleImageFetch(from: imageUrl) await MainActor.run { self.image = NSImage(data: fetchedImage) } } catch { print("Got error \(error)") } } } } else { image = nil } } } } func simpleImageFetch(from urlString: String) async throws -> Data { guard let url = URL(string: urlString) else { throw URLError(.badURL) } let (data, response) = try await URLSession.shared.data(from: url) guard let httpResponse = response as? HTTPURLResponse else { throw URLError(.badServerResponse) } image = NSImage(data: data) return data } } ================================================ FILE: There/Views/Timezone/NotFoundView.swift ================================================ import SwiftUI struct NotFoundView: View { var body: some View { VStack(spacing: 2) { Text("😕") .font(.largeTitle) Text("Entry not found") .font(.title) .fontWeight(.medium) } } } #Preview { NotFoundView() .frame(width: 320, height: 320) } ================================================ FILE: There/pm.there.There.LaunchAgent.plist ================================================ Label pm.there.There.LaunchAgent ProgramArguments /Applications/There.app/Contents/MacOS/There RunAtLoad KeepAlive LimitLoadToSessionType Aqua ================================================ FILE: ThereTests/ThereTests.swift ================================================ // // ThereTests.swift // ThereTests // // Created by Dena Sohrabi on 9/2/24. // import Testing struct ThereTests { @Test func example() async throws { // Write your test here and use APIs like `#expect(...)` to check expected conditions. } } ================================================ FILE: ThereUITests/ThereUITests.swift ================================================ // ThereUITests.swift // ThereUITests // // Created by Dena Sohrabi on 9/2/24. // import XCTest final class ThereUITests: XCTestCase { let app = XCUIApplication() override func setUpWithError() throws { continueAfterFailure = false app.launch() } override func tearDownWithError() throws { // Terminate the app after each test app.terminate() } func printAccessibleElements() { let allElements = app.descendants(matching: .any) for element in allElements.allElementsBoundByIndex { print("Element: \(element.debugDescription)") } } @MainActor func testUIElementsExistence() throws { // Print all accessible elements print("Printing all accessible elements:") printAccessibleElements() // Check for specific element types print("\nSearching for specific element types:") let searchFields = app.searchFields.allElementsBoundByIndex print("Search Fields: \(searchFields.map { $0.debugDescription })") let textFields = app.textFields.allElementsBoundByIndex print("Text Fields: \(textFields.map { $0.debugDescription })") let buttons = app.buttons.allElementsBoundByIndex print("Buttons: \(buttons.map { $0.debugDescription })") let tables = app.tables.allElementsBoundByIndex print("Tables: \(tables.map { $0.debugDescription })") // Try to find any input field let possibleInputFields = app.descendants(matching: .any).matching(NSPredicate(format: "type == 'XCUIElementTypeTextField' OR type == 'XCUIElementTypeSearchField'")) print("\nPossible Input Fields:") for field in possibleInputFields.allElementsBoundByIndex { print(field.debugDescription) } // Assert that we found at least one possible input field XCTAssertTrue(possibleInputFields.count > 0, "No input fields found in the app") } @MainActor func testLaunchPerformance() throws { if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { measure(metrics: [XCTApplicationLaunchMetric()]) { XCUIApplication().launch() } } } } ================================================ FILE: ThereUITests/ThereUITestsLaunchTests.swift ================================================ // // ThereUITestsLaunchTests.swift // ThereUITests // // Created by Dena Sohrabi on 9/2/24. // import XCTest final class ThereUITestsLaunchTests: XCTestCase { override class var runsForEachTargetApplicationUIConfiguration: Bool { true } override func setUpWithError() throws { continueAfterFailure = false } @MainActor func testLaunch() throws { let app = XCUIApplication() app.launch() // Insert steps here to perform after app launch but before taking a screenshot, // such as logging into a test account or navigating somewhere in the app let attachment = XCTAttachment(screenshot: app.screenshot()) attachment.name = "Launch Screen" attachment.lifetime = .keepAlways add(attachment) } } ================================================ FILE: readme.md ================================================ # [There](https://there.pm) [![Swift](https://img.shields.io/badge/Swift-F54A2A?logo=swift&logoColor=white)](#) [![macOS](https://img.shields.io/badge/macOS-000000?logo=apple&logoColor=F0F0F0)](#) A native menubar app to track friends, teammates or city time zones on macOS. ``` brew install --cask there ``` ![Screen-shot of the app](https://there.pm/app@2x.jpg) - Add photos for people from X (Twitter), Telegram or pick locally - Add any city without knowing the time zone - Supports raw UTC offsets - 0-1% idle CPU usage - Ultra-low memory - Written in Swift UI - macOS 13+ ## Roadmap We want to keep the app as simple as possible. But here are the things we're considering for future versions: - [ ] Widgets - [ ] Time slider to view future/past times - [ ] Auto-update - [ ] Apple Script API for third-party integrations i.e. Raycast ## Contributions Contributions are welcome! For simple fixes or improvements feel free to open a PR with the smallest change. For features or improvements that add scope to the app or have user facing changes, please open an issue first to discuss your proposal. If you want to tackle any of the items in the roadmap section, please also open an issue. ## License This project is licensed under the [MIT License](LICENSE)