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 <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databaseconnections>).
///
/// 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 <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databaseconnections>
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 <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/database/trace(options:_:)>
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 <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/configuration/publicstatementarguments>
config.publicStatementArguments = true
#endif
return config
}
}
// MARK: - Database Migrations
extension AppDatabase {
/// The DatabaseMigrator that defines the database schema.
///
/// See <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/migrations>
private var migrator: DatabaseMigrator {
var migrator = DatabaseMigrator()
#if DEBUG
// Speed up development by nuking the database when migrations change
// See <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/migrations>
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 = """
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>pm.there.There.LaunchAgent</string>
<key>ProgramArguments</key>
<array>
<string>/Applications/There.app/Contents/MacOS/There</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>LimitLoadToSessionType</key>
<string>Aqua</string>
</dict>
</plist>
"""
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
// <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databaseconnections>
//
// 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
================================================
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.maps</key>
<true/>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.pm.there.There</string>
</array>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.personal-information.location</key>
<true/>
</dict>
</plist>
================================================
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<Timer.TimerPublisher>?
@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<Void, Never>?
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<Void, Never>?
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<Void, Never>?
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
================================================
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>pm.there.There.LaunchAgent</string>
<key>ProgramArguments</key>
<array>
<string>/Applications/There.app/Contents/MacOS/There</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>LimitLoadToSessionType</key>
<string>Aqua</string>
</dict>
</plist>
================================================
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)
[](#) [](#)
A native menubar app to track friends, teammates or city time zones on macOS.
```
brew install --cask there
```

- 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)
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
Condensed preview — 60 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (123K chars).
[
{
"path": ".gitignore",
"chars": 881,
"preview": "# Xcode\n.DS_Store\nxcuserdata/\n*.xcodeproj/*\n!*.xcodeproj/project.pbxproj\n!*.xcodeproj/xcshareddata/\n!*.xcodeproj/project"
},
{
"path": "LICENSE",
"chars": 1121,
"preview": "### MIT License\n\n```\nMIT License\n\nCopyright (c) 2024 Dena Sohrabi\n\nPermission is hereby granted, free of charge, to any "
},
{
"path": "There/Assets.xcassets/AccentColor.colorset/Contents.json",
"chars": 123,
"preview": "{\n \"colors\" : [\n {\n \"idiom\" : \"universal\"\n }\n ],\n \"info\" : {\n \"author\" : \"xcode\",\n \"version\" : 1\n }"
},
{
"path": "There/Assets.xcassets/AppIcon.appiconset/Contents.json",
"chars": 1297,
"preview": "{\n \"images\" : [\n {\n \"filename\" : \"icon-166.png\",\n \"idiom\" : \"mac\",\n \"scale\" : \"1x\",\n \"size\" : \"1"
},
{
"path": "There/Assets.xcassets/Contents.json",
"chars": 63,
"preview": "{\n \"info\" : {\n \"author\" : \"xcode\",\n \"version\" : 1\n }\n}\n"
},
{
"path": "There/Assets.xcassets/Earth.imageset/Contents.json",
"chars": 307,
"preview": "{\n \"images\" : [\n {\n \"idiom\" : \"universal\",\n \"scale\" : \"1x\"\n },\n {\n \"filename\" : \"Earch&Sun.png\""
},
{
"path": "There/Assets.xcassets/Logo.imageset/Contents.json",
"chars": 307,
"preview": "{\n \"images\" : [\n {\n \"idiom\" : \"universal\",\n \"scale\" : \"1x\"\n },\n {\n \"filename\" : \"ThereIcon.png\""
},
{
"path": "There/Assets.xcassets/Night.imageset/Contents.json",
"chars": 303,
"preview": "{\n \"images\" : [\n {\n \"filename\" : \"Night.png\",\n \"idiom\" : \"universal\",\n \"scale\" : \"1x\"\n },\n {\n "
},
{
"path": "There/Assets.xcassets/appIcon.imageset/Contents.json",
"chars": 463,
"preview": "{\n \"images\" : [\n {\n \"filename\" : \"iconTemplate.png\",\n \"idiom\" : \"universal\",\n \"scale\" : \"1x\"\n },\n "
},
{
"path": "There/Assets.xcassets/early-afternoon.imageset/Contents.json",
"chars": 313,
"preview": "{\n \"images\" : [\n {\n \"filename\" : \"Early afternoon.png\",\n \"idiom\" : \"universal\",\n \"scale\" : \"1x\"\n }"
},
{
"path": "There/Assets.xcassets/early-evening.imageset/Contents.json",
"chars": 311,
"preview": "{\n \"images\" : [\n {\n \"filename\" : \"Early Eavning.png\",\n \"idiom\" : \"universal\",\n \"scale\" : \"1x\"\n },\n"
},
{
"path": "There/Assets.xcassets/early-morning.imageset/Contents.json",
"chars": 311,
"preview": "{\n \"images\" : [\n {\n \"filename\" : \"Early Morning.png\",\n \"idiom\" : \"universal\",\n \"scale\" : \"1x\"\n },\n"
},
{
"path": "There/Assets.xcassets/evening.imageset/Contents.json",
"chars": 305,
"preview": "{\n \"images\" : [\n {\n \"filename\" : \"Eavning.png\",\n \"idiom\" : \"universal\",\n \"scale\" : \"1x\"\n },\n {\n"
},
{
"path": "There/Assets.xcassets/late-afternoon.imageset/Contents.json",
"chars": 312,
"preview": "{\n \"images\" : [\n {\n \"filename\" : \"Late Afternoon.png\",\n \"idiom\" : \"universal\",\n \"scale\" : \"1x\"\n },"
},
{
"path": "There/Assets.xcassets/late-morning.imageset/Contents.json",
"chars": 310,
"preview": "{\n \"images\" : [\n {\n \"filename\" : \"Late Morning.png\",\n \"idiom\" : \"universal\",\n \"scale\" : \"1x\"\n },\n "
},
{
"path": "There/Assets.xcassets/telegram-logo.imageset/Contents.json",
"chars": 302,
"preview": "{\n \"images\" : [\n {\n \"filename\" : \"Logo.png\",\n \"idiom\" : \"universal\",\n \"scale\" : \"1x\"\n },\n {\n "
},
{
"path": "There/Assets.xcassets/twitter.imageset/Contents.json",
"chars": 305,
"preview": "{\n \"images\" : [\n {\n \"filename\" : \"twitter.png\",\n \"idiom\" : \"universal\",\n \"scale\" : \"1x\"\n },\n {\n"
},
{
"path": "There/ContentView.swift",
"chars": 641,
"preview": "import SwiftUI\n\nstruct ContentView: View {\n @EnvironmentObject var router: Router\n var body: some View {\n s"
},
{
"path": "There/Data/Database.swift",
"chars": 5231,
"preview": "import Foundation\nimport GRDB\nimport os.log\n\n///\n/// You create an `AppDatabase` with a connection to an SQLite database"
},
{
"path": "There/Data/Entry.swift",
"chars": 2863,
"preview": "import Foundation\nimport GRDB\nimport SwiftUI\n\nenum EntryType: String, Codable {\n case place\n case person\n}\n\nenum D"
},
{
"path": "There/Data/Fetcher.swift",
"chars": 954,
"preview": "import Combine\nimport Foundation\nimport GRDB\n\nenum SortOrder {\n case timeAscending\n case timeDescending\n}\n\nclass F"
},
{
"path": "There/Data/LaunchAgent.swift",
"chars": 3785,
"preview": "import Foundation\nimport ServiceManagement\n\nfunc installLaunchAgent() {\n let fileManager = FileManager.default\n\n g"
},
{
"path": "There/Data/Persistence.swift",
"chars": 2926,
"preview": "import Foundation\nimport GRDB\n\nextension AppDatabase {\n /// The database for the application\n static let shared = "
},
{
"path": "There/Data/Router.swift",
"chars": 438,
"preview": "import SwiftUI\n\nenum Route {\n case addTimezone\n case mainView\n case editTimeZone(entryId: Int64?)\n}\n\nclass Rout"
},
{
"path": "There/Data/SearchCompleter.swift",
"chars": 9392,
"preview": "import Foundation\nimport MapKit\n\nclass TimeZoneSearchCompiler: NSObject {\n private var commonAbbreviations: [String: "
},
{
"path": "There/Data/Utilities.swift",
"chars": 1060,
"preview": "import Foundation\nimport SwiftUI\n\nclass Utils {\n public static var shared = Utils()\n func selectPhoto() -> NSImage"
},
{
"path": "There/Preview Content/Preview Assets.xcassets/Contents.json",
"chars": 63,
"preview": "{\n \"info\" : {\n \"author\" : \"xcode\",\n \"version\" : 1\n }\n}\n"
},
{
"path": "There/There.entitlements",
"chars": 645,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
},
{
"path": "There/ThereApp.swift",
"chars": 3031,
"preview": "//\n// ThereApp.swift\n// There\n//\n// Created by Dena Sohrabi on 9/2/24.\n//\n\nimport AppKit\nimport MenuBarExtraAccess\nim"
},
{
"path": "There/UI/Button.swift",
"chars": 7004,
"preview": "import SwiftUI\n\n// PrimaryButton\nstruct PrimaryButton: View {\n var title: String\n var action: () -> Void\n\n var "
},
{
"path": "There/UI/Heading.swift",
"chars": 226,
"preview": "import SwiftUI\n\nstruct Heading: View {\n let title: String\n\n var body: some View {\n Text(title)\n "
},
{
"path": "There/UI/Input.swift",
"chars": 3214,
"preview": "import SwiftUI\n\nstruct AdaptiveColors {\n static let textFieldBackground = Color(.textBackgroundColor)\n static let "
},
{
"path": "There/UI/Label.swift",
"chars": 273,
"preview": "import SwiftUI\n\nstruct StyledLabel: View {\n let title: String\n \n var body: some View {\n Text(title)\n "
},
{
"path": "There/UI/LocalImageView.swift",
"chars": 1269,
"preview": "import SwiftUI\n\nstruct LocalImageView: View {\n let imageURL: URL\n\n @State private var image: NSImage?\n @State p"
},
{
"path": "There/UI/Titlebar.swift",
"chars": 1383,
"preview": "import SwiftUI\n\nstruct Titlebar: View {\n @EnvironmentObject var router: Router\n @State var hovered: Bool = false\n "
},
{
"path": "There/UI/TransparentBackgroundView.swift",
"chars": 426,
"preview": "import SwiftUI\n\nstruct TransparentBackgroundView: NSViewRepresentable {\n func makeNSView(context: Context) -> NSVisua"
},
{
"path": "There/Views/ButtomBar/AddButton.swift",
"chars": 361,
"preview": "import SwiftUI\n\nstruct AddButton: View {\n @State private var addHovered: Bool = false\n @EnvironmentObject var appS"
},
{
"path": "There/Views/ButtomBar/BottomBarView.swift",
"chars": 1073,
"preview": "import SwiftUI\n\nstruct BottomBarView: View {\n @Binding var isAtBottom: Bool\n @Binding var sortOrder: SortOrder\n "
},
{
"path": "There/Views/ButtomBar/SettingsButton.swift",
"chars": 5015,
"preview": "import SwiftUI\n\nstruct SettingsButton: View {\n @State private var settingsHovered: Bool = false\n @Environment(\\.op"
},
{
"path": "There/Views/CLAuthorizationStatus+Description.swift ",
"chars": 396,
"preview": "import CoreLocation\n\nextension CLAuthorizationStatus: CustomStringConvertible {\n public var description: String {\n "
},
{
"path": "There/Views/EmptyTimezoneView.swift",
"chars": 557,
"preview": "import SwiftUI\n\nstruct EmptyTimezoneView: View {\n var body: some View {\n VStack {\n Image(\"Earth\")\n "
},
{
"path": "There/Views/EntryUI/EntryIcon.swift",
"chars": 3947,
"preview": "import CoreLocation\nimport SwiftUI\n\nstruct EntryIcon: View {\n let entry: Entry\n @Environment(\\.colorScheme) var sc"
},
{
"path": "There/Views/EntryUI/EntryRow.swift",
"chars": 4373,
"preview": "import Combine\nimport SwiftUI\n\nstruct EntryRow: View {\n let entry: Entry\n @State private var isHovered: Bool = fal"
},
{
"path": "There/Views/MainView.swift",
"chars": 6480,
"preview": "import AppKit\nimport GRDB\nimport PostHog\nimport SwiftUI\n\nstruct MainView: View {\n @StateObject private var fetcher = "
},
{
"path": "There/Views/Onboarding/InitialView.swift",
"chars": 2131,
"preview": "// InitialView.swift\n\nimport PostHog\nimport SwiftUI\n\nstruct InitialView: View {\n @Environment(\\.database) var databas"
},
{
"path": "There/Views/Onboarding/LeftPanel.swift",
"chars": 1611,
"preview": "// LeftPanel.swift\n\nimport SwiftUI\n\nstruct LeftPanel: View {\n var body: some View {\n VStack(alignment: .leadin"
},
{
"path": "There/Views/Onboarding/RightPanel.swift",
"chars": 1095,
"preview": "// RightPanel.swift\n\nimport PostHog\nimport SwiftUI\n\nstruct RightPanel: View {\n @Binding var email: String\n let sav"
},
{
"path": "There/Views/Timezone/AddTimezone + Components.swift",
"chars": 13525,
"preview": "import AppKit\nimport MapKit\nimport SwiftUI\n\n// MARK: - IconView\n\nstruct IconView: View {\n @Binding var image: NSImage"
},
{
"path": "There/Views/Timezone/AddTimezone + Functions.swift",
"chars": 2760,
"preview": "import AppKit\nimport CoreLocation\nimport Foundation\nimport MapKit\nimport SwiftUI\n\nextension AddTimezone {\n func searc"
},
{
"path": "There/Views/Timezone/AddTimezone.swift",
"chars": 1579,
"preview": "import CoreLocation\nimport MapKit\nimport SwiftUI\n\nstruct AddTimezone: View {\n @Environment(\\.database) var database\n "
},
{
"path": "There/Views/Timezone/EditTimeZone + Functions.swift",
"chars": 1552,
"preview": "import AppKit\nimport CoreLocation\nimport Foundation\nimport MapKit\nimport SwiftUI\n\nextension EditTimeZoneView {\n func "
},
{
"path": "There/Views/Timezone/EditTimeZoneView.swift",
"chars": 3565,
"preview": "import CoreLocation\nimport MapKit\nimport SwiftUI\n\nstruct EditTimeZoneView: View {\n var entryId: Int64?\n @Environme"
},
{
"path": "There/Views/Timezone/FormSection.swift",
"chars": 3391,
"preview": "import PostHog\nimport SwiftUI\nimport UserNotifications\n\nstruct FormSection: View {\n @Binding var name: String\n @Bi"
},
{
"path": "There/Views/Timezone/IconSection.swift",
"chars": 3077,
"preview": "import SwiftUI\n\nstruct IconSection: View {\n @Binding var image: NSImage?\n @Binding var countryEmoji: String\n @B"
},
{
"path": "There/Views/Timezone/NotFoundView.swift",
"chars": 350,
"preview": "import SwiftUI\n\nstruct NotFoundView: View {\n var body: some View {\n VStack(spacing: 2) {\n Text(\"😕\")"
},
{
"path": "There/pm.there.There.LaunchAgent.plist",
"chars": 522,
"preview": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/P"
},
{
"path": "ThereTests/ThereTests.swift",
"chars": 267,
"preview": "//\n// ThereTests.swift\n// ThereTests\n//\n// Created by Dena Sohrabi on 9/2/24.\n//\n\nimport Testing\n\nstruct ThereTests {"
},
{
"path": "ThereUITests/ThereUITests.swift",
"chars": 2255,
"preview": "// ThereUITests.swift\n// ThereUITests\n//\n// Created by Dena Sohrabi on 9/2/24.\n//\n\nimport XCTest\n\nfinal class ThereUI"
},
{
"path": "ThereUITests/ThereUITestsLaunchTests.swift",
"chars": 810,
"preview": "//\n// ThereUITestsLaunchTests.swift\n// ThereUITests\n//\n// Created by Dena Sohrabi on 9/2/24.\n//\n\nimport XCTest\n\nfinal"
},
{
"path": "readme.md",
"chars": 1276,
"preview": "# [There](https://there.pm)\n\n[](#) [![macO"
}
]
About this extraction
This page contains the full source code of the dena-sohrabi/There GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 60 files (111.5 KB), approximately 27.5k tokens. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.