From 71db6d4db118796d260b6cc54ba76e06e7b17411 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Sun, 18 Jan 2026 21:22:50 +0000 Subject: [PATCH] feat: Add Travel tab with live countdown timer and pre-arrival notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New Travel tab with flight status, progress bar, and quick destination picker - Live countdown timer in menu bar during flight (βœˆοΈπŸ‡ΊπŸ‡Έ 5:32 format) - Pre-arrival notifications (configurable: 2min, 1min, 30sec, 10sec before landing) - Country flag emojis for all 11 Torn destinations - Live countdown updates every second in both menu bar and app views - Bump version to 1.4 --- MacTorn/MacTorn.xcodeproj/project.pbxproj | 16 +- MacTorn/MacTorn/MacTornApp.swift | 65 +++- MacTorn/MacTorn/Models/TornModels.swift | 185 +++++++-- .../Utilities/NotificationManager.swift | 46 ++- MacTorn/MacTorn/ViewModels/AppState.swift | 128 ++++++- MacTorn/MacTorn/Views/AttacksView.swift | 45 ++- .../MacTorn/Views/Components/ChainView.swift | 48 +-- MacTorn/MacTorn/Views/ContentView.swift | 7 +- MacTorn/MacTorn/Views/StatusView.swift | 6 +- MacTorn/MacTorn/Views/TravelView.swift | 350 ++++++++++++++++++ README.md | 7 + 11 files changed, 819 insertions(+), 84 deletions(-) create mode 100644 MacTorn/MacTorn/Views/TravelView.swift diff --git a/MacTorn/MacTorn.xcodeproj/project.pbxproj b/MacTorn/MacTorn.xcodeproj/project.pbxproj index 7c5becb..608ac0d 100644 --- a/MacTorn/MacTorn.xcodeproj/project.pbxproj +++ b/MacTorn/MacTorn.xcodeproj/project.pbxproj @@ -28,6 +28,7 @@ AAA00019 /* WatchlistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10020 /* WatchlistView.swift */; }; AAA00020 /* PropertiesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10021 /* PropertiesView.swift */; }; AAA00021 /* NetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10022 /* NetworkSession.swift */; }; + AAA00022 /* TravelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10023 /* TravelView.swift */; }; /* Unit Tests */ BBB00001 /* MockNetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10001 /* MockNetworkSession.swift */; }; BBB00002 /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10002 /* TestHelpers.swift */; }; @@ -85,6 +86,7 @@ AAA10020 /* WatchlistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchlistView.swift; sourceTree = ""; }; AAA10021 /* PropertiesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropertiesView.swift; sourceTree = ""; }; AAA10022 /* NetworkSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkSession.swift; sourceTree = ""; }; + AAA10023 /* TravelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TravelView.swift; sourceTree = ""; }; AAA10000 /* MacTorn.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacTorn.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* Unit Test Files */ BBB10001 /* MockNetworkSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNetworkSession.swift; sourceTree = ""; }; @@ -187,6 +189,7 @@ AAA10002 /* ContentView.swift */, AAA10006 /* SettingsView.swift */, AAA10007 /* StatusView.swift */, + AAA10023 /* TravelView.swift */, AAA10017 /* MoneyView.swift */, AAA10018 /* AttacksView.swift */, AAA10019 /* FactionView.swift */, @@ -437,6 +440,7 @@ AAA00019 /* WatchlistView.swift in Sources */, AAA00020 /* PropertiesView.swift in Sources */, AAA00021 /* NetworkSession.swift in Sources */, + AAA00022 /* TravelView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -618,7 +622,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.app; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -645,7 +649,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.app; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -663,7 +667,7 @@ DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -681,7 +685,7 @@ DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -699,7 +703,7 @@ DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -716,7 +720,7 @@ DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; diff --git a/MacTorn/MacTorn/MacTornApp.swift b/MacTorn/MacTorn/MacTornApp.swift index 8bbaf22..8dba5f0 100644 --- a/MacTorn/MacTorn/MacTornApp.swift +++ b/MacTorn/MacTorn/MacTornApp.swift @@ -3,42 +3,83 @@ import SwiftUI @main struct MacTornApp: App { @StateObject private var appState = AppState() - + var body: some Scene { MenuBarExtra { ContentView() .environmentObject(appState) } label: { - Image(systemName: menuBarIcon) - .renderingMode(.template) + MenuBarLabel(appState: appState) } .menuBarExtraStyle(.window) } - +} + +// MARK: - Menu Bar Label +struct MenuBarLabel: View { + @ObservedObject var appState: AppState + + var body: some View { + // Show airplane + flag + countdown when traveling + if let travel = appState.data?.travel, + travel.isTraveling { + let destination = travel.destination ?? "?" + let flag = flagForDestination(destination) + let time = formatShortTime(appState.travelSecondsRemaining) + Text("✈️\(flag)\(time)") + } else { + Image(systemName: menuBarIcon) + } + } + private var menuBarIcon: String { // Error state if appState.errorMsg != nil { return "exclamationmark.triangle.fill" } - - // Traveling state - if let travel = appState.data?.travel, travel.isTraveling { - return "airplane" - } - + // Abroad state if let travel = appState.data?.travel, travel.isAbroad { return "globe" } - + // Energy full state if let bars = appState.data?.bars { if bars.energy.current >= bars.energy.maximum { return "bolt.fill" } } - + // Default return "bolt" } + + private func flagForDestination(_ destination: String) -> String { + switch destination.lowercased() { + case "mexico": return "πŸ‡²πŸ‡½" + case "cayman islands": return "πŸ‡°πŸ‡Ύ" + case "canada": return "πŸ‡¨πŸ‡¦" + case "hawaii": return "πŸ‡ΊπŸ‡Έ" + case "united kingdom": return "πŸ‡¬πŸ‡§" + case "argentina": return "πŸ‡¦πŸ‡·" + case "switzerland": return "πŸ‡¨πŸ‡­" + case "japan": return "πŸ‡―πŸ‡΅" + case "china": return "πŸ‡¨πŸ‡³" + case "uae": return "πŸ‡¦πŸ‡ͺ" + case "south africa": return "πŸ‡ΏπŸ‡¦" + case "torn": return "πŸ‡ΊπŸ‡Έ" + default: return "🌍" + } + } + + private func formatShortTime(_ seconds: Int) -> String { + if seconds <= 0 { return "0:00" } + let hours = seconds / 3600 + let minutes = (seconds % 3600) / 60 + let secs = seconds % 60 + if hours > 0 { + return String(format: "%d:%02d:%02d", hours, minutes, secs) + } + return String(format: "%d:%02d", minutes, secs) + } } diff --git a/MacTorn/MacTorn/Models/TornModels.swift b/MacTorn/MacTorn/Models/TornModels.swift index 335e02d..a338279 100644 --- a/MacTorn/MacTorn/Models/TornModels.swift +++ b/MacTorn/MacTorn/Models/TornModels.swift @@ -90,28 +90,129 @@ struct Travel: Codable, Equatable { let timestamp: Int? let departed: Int? let timeLeft: Int? - + enum CodingKeys: String, CodingKey { case destination case timestamp case departed case timeLeft = "time_left" } - + var isAbroad: Bool { guard let dest = destination, let time = timeLeft else { return false } return dest != "Torn" && time == 0 } - + var isTraveling: Bool { guard let time = timeLeft else { return false } return time > 0 } - + var arrivalDate: Date? { guard isTraveling, let ts = timestamp else { return nil } return Date(timeIntervalSince1970: TimeInterval(ts)) } + + /// Calculate remaining seconds based on fetch time (for live countdown) + func remainingSeconds(from fetchTime: Date) -> Int { + guard let timeLeft = timeLeft, timeLeft > 0 else { return 0 } + let elapsed = Int(Date().timeIntervalSince(fetchTime)) + return max(0, timeLeft - elapsed) + } + + /// Calculate flight progress (0.0 to 1.0) based on fetch time + func flightProgress(from fetchTime: Date) -> Double { + guard let departed = departed, let timestamp = timestamp else { return 0 } + let totalDuration = timestamp - departed + guard totalDuration > 0 else { return 0 } + let remaining = remainingSeconds(from: fetchTime) + let elapsed = totalDuration - remaining + return min(1.0, max(0.0, Double(elapsed) / Double(totalDuration))) + } +} + +// MARK: - Travel Destinations +enum TornDestination: String, CaseIterable, Identifiable { + case mexico = "Mexico" + case caymanIslands = "Cayman Islands" + case canada = "Canada" + case hawaii = "Hawaii" + case unitedKingdom = "United Kingdom" + case argentina = "Argentina" + case switzerland = "Switzerland" + case japan = "Japan" + case china = "China" + case uae = "UAE" + case southAfrica = "South Africa" + + var id: String { rawValue } + + var flag: String { + switch self { + case .mexico: return "πŸ‡²πŸ‡½" + case .caymanIslands: return "πŸ‡°πŸ‡Ύ" + case .canada: return "πŸ‡¨πŸ‡¦" + case .hawaii: return "πŸ‡ΊπŸ‡Έ" + case .unitedKingdom: return "πŸ‡¬πŸ‡§" + case .argentina: return "πŸ‡¦πŸ‡·" + case .switzerland: return "πŸ‡¨πŸ‡­" + case .japan: return "πŸ‡―πŸ‡΅" + case .china: return "πŸ‡¨πŸ‡³" + case .uae: return "πŸ‡¦πŸ‡ͺ" + case .southAfrica: return "πŸ‡ΏπŸ‡¦" + } + } + + /// Approximate flight time in minutes + var flightTimeMinutes: Int { + switch self { + case .mexico: return 26 + case .caymanIslands: return 35 + case .canada: return 41 + case .hawaii: return 134 + case .unitedKingdom: return 159 + case .argentina: return 167 + case .switzerland: return 175 + case .japan: return 225 + case .china: return 242 + case .uae: return 271 + case .southAfrica: return 297 + } + } + + var flightTimeFormatted: String { + let hours = flightTimeMinutes / 60 + let minutes = flightTimeMinutes % 60 + if hours > 0 { + return "\(hours)h \(minutes)m" + } + return "\(minutes)m" + } + + var travelAgencyURL: URL { + URL(string: "https://www.torn.com/travelagency.php")! + } +} + +// MARK: - Travel Notification Settings +struct TravelNotificationSetting: Codable, Identifiable, Equatable { + let id: String + let secondsBefore: Int + var enabled: Bool + + var displayName: String { + if secondsBefore >= 60 { + return "\(secondsBefore / 60) min before" + } + return "\(secondsBefore) sec before" + } + + static let defaults: [TravelNotificationSetting] = [ + TravelNotificationSetting(id: "travel_2min", secondsBefore: 120, enabled: false), + TravelNotificationSetting(id: "travel_1min", secondsBefore: 60, enabled: true), + TravelNotificationSetting(id: "travel_30sec", secondsBefore: 30, enabled: false), + TravelNotificationSetting(id: "travel_10sec", secondsBefore: 10, enabled: false) + ] } // MARK: - Status (Hospital/Jail) @@ -258,39 +359,81 @@ struct AttackResult: Codable, Identifiable { let code: String? let timestampStarted: Int? let timestampEnded: Int? - let opponentId: Int? - let opponentName: String? + let attackerId: Int? + let attackerName: String? + let defenderId: Int? + let defenderName: String? let result: String? let respect: Double? - + var id: String { code ?? UUID().uuidString } - + enum CodingKeys: String, CodingKey { case code case timestampStarted = "timestamp_started" case timestampEnded = "timestamp_ended" - case opponentId = "defender_id" - case opponentName = "defender_name" + case attackerId = "attacker_id" + case attackerName = "attacker_name" + case defenderId = "defender_id" + case defenderName = "defender_name" case result, respect } + + func opponentName(forUserId userId: Int) -> String { + let name: String? + if attackerId == userId { + name = defenderName + } else { + name = attackerName + } + + if let name = name, !name.isEmpty { + return name + } + return "Someone" + } + + func opponentId(forUserId userId: Int) -> Int? { + if attackerId == userId { + return defenderId + } else { + return attackerId + } + } + + func wasAttacker(userId: Int) -> Bool { + return attackerId == userId + } - var resultIcon: String { + func resultIcon(forUserId userId: Int) -> String { + let userWasAttacker = wasAttacker(userId: userId) switch result { - case "Attacked": return "checkmark.circle.fill" - case "Mugged": return "dollarsign.circle.fill" - case "Hospitalized": return "cross.circle.fill" - case "Lost": return "xmark.circle.fill" + case "Attacked": return userWasAttacker ? "checkmark.circle.fill" : "xmark.circle.fill" + case "Mugged": return userWasAttacker ? "dollarsign.circle.fill" : "xmark.circle.fill" + case "Hospitalized": return userWasAttacker ? "cross.circle.fill" : "xmark.circle.fill" + case "Lost": return userWasAttacker ? "xmark.circle.fill" : "shield.checkered" case "Stalemate": return "equal.circle.fill" + case "Escape": return userWasAttacker ? "figure.run" : "shield.checkered" + case "Assist": return "person.2.fill" default: return "questionmark.circle" } } - - var resultColor: Color { + + func resultColor(forUserId userId: Int) -> Color { + let userWasAttacker = wasAttacker(userId: userId) switch result { - case "Attacked", "Mugged", "Hospitalized": return .green - case "Lost": return .red - case "Stalemate": return .orange - default: return .gray + case "Attacked", "Mugged", "Hospitalized": + return userWasAttacker ? .green : .red + case "Lost": + return userWasAttacker ? .red : .green + case "Stalemate": + return .orange + case "Escape": + return userWasAttacker ? .orange : .green + case "Assist": + return .blue + default: + return .gray } } diff --git a/MacTorn/MacTorn/Utilities/NotificationManager.swift b/MacTorn/MacTorn/Utilities/NotificationManager.swift index 34cc09e..816cf00 100644 --- a/MacTorn/MacTorn/Utilities/NotificationManager.swift +++ b/MacTorn/MacTorn/Utilities/NotificationManager.swift @@ -13,12 +13,13 @@ enum NotificationType: String { case nerve case happy case life + case travelApproaching var url: URL { switch self { case .drugReady, .medicalReady, .boosterReady: return URL(string: "https://www.torn.com/item.php")! - case .landed: + case .landed, .travelApproaching: return URL(string: "https://www.torn.com/page.php?sid=ItemMarket")! case .chainExpiring: return URL(string: "https://www.torn.com/factions.php?step=your#/tab=wars")! @@ -74,6 +75,49 @@ class NotificationManager: NSObject, UNUserNotificationCenterDelegate { } } + /// Schedule a notification for a specific date + func scheduleNotification(title: String, body: String, type: NotificationType, at date: Date, identifier: String) { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + content.categoryIdentifier = type.rawValue + + let triggerDate = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date) + let trigger = UNCalendarNotificationTrigger(dateMatching: triggerDate, repeats: false) + + let request = UNNotificationRequest( + identifier: identifier, + content: content, + trigger: trigger + ) + + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + print("Scheduled notification error: \(error)") + } else { + print("Scheduled notification '\(identifier)' for \(date)") + } + } + } + + /// Cancel all travel-related notifications + func cancelTravelNotifications() { + let identifiers = [ + "travel_2min_alert", + "travel_1min_alert", + "travel_30sec_alert", + "travel_10sec_alert" + ] + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: identifiers) + print("Cancelled travel notifications") + } + + /// Cancel a specific notification by identifier + func cancelNotification(identifier: String) { + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [identifier]) + } + // MARK: - UNUserNotificationCenterDelegate func userNotificationCenter( diff --git a/MacTorn/MacTorn/ViewModels/AppState.swift b/MacTorn/MacTorn/ViewModels/AppState.swift index fe6a490..138956c 100644 --- a/MacTorn/MacTorn/ViewModels/AppState.swift +++ b/MacTorn/MacTorn/ViewModels/AppState.swift @@ -17,6 +17,7 @@ class AppState: ObservableObject { @Published var errorMsg: String? @Published var isLoading: Bool = false @Published var notificationRules: [NotificationRule] = [] + @Published var travelNotificationSettings: [TravelNotificationSetting] = [] // MARK: - New Data Sources @Published var moneyData: MoneyData? @@ -29,6 +30,13 @@ class AppState: ObservableObject { // MARK: - Update State @Published var updateAvailable: GitHubRelease? + // MARK: - Fetch Time (for live countdown calculations) + @Published var lastFetchTime: Date = Date() + + // MARK: - Live Travel Countdown + @Published var travelSecondsRemaining: Int = 0 + private var travelTimerCancellable: AnyCancellable? + // MARK: - Managers let launchAtLogin = LaunchAtLoginManager() let shortcutsManager = ShortcutsManager() @@ -50,6 +58,7 @@ class AppState: ObservableObject { init(session: NetworkSession = URLSession.shared) { self.session = session loadNotificationRules() + loadTravelNotificationSettings() loadWatchlist() // Polling and permissions moved to onAppear in UI } @@ -77,7 +86,103 @@ class AppState: ObservableObject { saveNotificationRules() } } - + + // MARK: - Travel Notification Settings + func loadTravelNotificationSettings() { + if let data = UserDefaults.standard.data(forKey: "travelNotificationSettings"), + let settings = try? JSONDecoder().decode([TravelNotificationSetting].self, from: data) { + travelNotificationSettings = settings + } else { + travelNotificationSettings = TravelNotificationSetting.defaults + saveTravelNotificationSettings() + } + } + + func saveTravelNotificationSettings() { + if let data = try? JSONEncoder().encode(travelNotificationSettings) { + UserDefaults.standard.set(data, forKey: "travelNotificationSettings") + } + } + + func updateTravelNotificationSetting(_ setting: TravelNotificationSetting) { + if let index = travelNotificationSettings.firstIndex(where: { $0.id == setting.id }) { + travelNotificationSettings[index] = setting + saveTravelNotificationSettings() + // Reschedule notifications if currently traveling + if let travel = data?.travel, travel.isTraveling { + scheduleTravelNotifications(for: travel) + } + } + } + + func scheduleTravelNotifications(for travel: Travel) { + // Cancel any existing travel notifications first + NotificationManager.shared.cancelTravelNotifications() + + guard let arrivalDate = travel.arrivalDate else { return } + + for setting in travelNotificationSettings where setting.enabled { + let notificationDate = arrivalDate.addingTimeInterval(-Double(setting.secondsBefore)) + + // Only schedule if the notification time is in the future + if notificationDate > Date() { + let identifier = "\(setting.id)_alert" + let timeText: String + if setting.secondsBefore >= 60 { + timeText = "\(setting.secondsBefore / 60) minute\(setting.secondsBefore >= 120 ? "s" : "")" + } else { + timeText = "\(setting.secondsBefore) seconds" + } + + NotificationManager.shared.scheduleNotification( + title: "Landing Soon!", + body: "You will arrive in \(travel.destination ?? "your destination") in \(timeText)", + type: .travelApproaching, + at: notificationDate, + identifier: identifier + ) + } + } + } + + // MARK: - Live Travel Timer + func manageTravelTimer() { + if let travel = data?.travel, travel.isTraveling { + // Start or continue timer + updateTravelSecondsRemaining() + if travelTimerCancellable == nil { + startTravelTimer() + } + } else { + // Stop timer when not traveling + stopTravelTimer() + } + } + + private func startTravelTimer() { + travelTimerCancellable?.cancel() + + travelTimerCancellable = Timer.publish(every: 1.0, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + self?.updateTravelSecondsRemaining() + } + } + + private func stopTravelTimer() { + travelTimerCancellable?.cancel() + travelTimerCancellable = nil + travelSecondsRemaining = 0 + } + + private func updateTravelSecondsRemaining() { + guard let travel = data?.travel, travel.isTraveling else { + travelSecondsRemaining = 0 + return + } + travelSecondsRemaining = travel.remainingSeconds(from: lastFetchTime) + } + // MARK: - Watchlist func loadWatchlist() { if let data = UserDefaults.standard.data(forKey: "watchlist"), @@ -379,8 +484,10 @@ class AppState: ObservableObject { code: code, timestampStarted: attackDict["timestamp_started"] as? Int, timestampEnded: attackDict["timestamp_ended"] as? Int, - opponentId: attackDict["defender_id"] as? Int, - opponentName: attackDict["defender_name"] as? String, + attackerId: attackDict["attacker_id"] as? Int, + attackerName: attackDict["attacker_name"] as? String, + defenderId: attackDict["defender_id"] as? Int, + defenderName: attackDict["defender_name"] as? String, result: attackDict["result"] as? String, respect: attackDict["respect"] as? Double ) @@ -441,8 +548,12 @@ class AppState: ObservableObject { if let p = result.4 { self.propertiesData = p } self.lastUpdated = Date() + self.lastFetchTime = Date() self.errorMsg = nil + // Manage travel timer after data is set + self.manageTravelTimer() + // Force UI update by triggering objectWillChange self.objectWillChange.send() logger.info("UI update triggered, lastUpdated: \(self.lastUpdated?.description ?? "nil")") @@ -512,9 +623,18 @@ class AppState: ObservableObject { } if let prevTravel = previousTravel, let currentTravel = newData.travel { + // Just landed if prevTravel.isTraveling && !currentTravel.isTraveling { - NotificationManager.shared.send(title: "Landed! ✈️", body: "You have arrived in \(currentTravel.destination ?? "destination")", type: .landed) + NotificationManager.shared.send(title: "Landed!", body: "You have arrived in \(currentTravel.destination ?? "destination")", type: .landed) + NotificationManager.shared.cancelTravelNotifications() } + // Just started traveling + if !prevTravel.isTraveling && currentTravel.isTraveling { + scheduleTravelNotifications(for: currentTravel) + } + } else if let currentTravel = newData.travel, currentTravel.isTraveling, previousTravel == nil { + // First data fetch while traveling + scheduleTravelNotifications(for: currentTravel) } if let chain = newData.chain, chain.isActive { diff --git a/MacTorn/MacTorn/Views/AttacksView.swift b/MacTorn/MacTorn/Views/AttacksView.swift index ef02e75..00383f1 100644 --- a/MacTorn/MacTorn/Views/AttacksView.swift +++ b/MacTorn/MacTorn/Views/AttacksView.swift @@ -51,23 +51,38 @@ struct AttacksView: View { .font(.caption.bold()) } - if let attacks = appState.recentAttacks, !attacks.isEmpty { + if let attacks = appState.recentAttacks, !attacks.isEmpty, + let userId = appState.data?.playerId { ForEach(attacks.prefix(5)) { attack in - HStack { - Image(systemName: attack.resultIcon) - .foregroundColor(attack.resultColor) - .frame(width: 16) - - Text(attack.opponentName ?? "Unknown") - .font(.caption) - .lineLimit(1) - - Spacer() - - Text(attack.timeAgo) - .font(.caption2) - .foregroundColor(.secondary) + Button { + if let opponentId = attack.opponentId(forUserId: userId), + let url = URL(string: "https://www.torn.com/profiles.php?XID=\(opponentId)") { + NSWorkspace.shared.open(url) + } + } label: { + HStack(spacing: 6) { + Image(systemName: attack.resultIcon(forUserId: userId)) + .foregroundColor(attack.resultColor(forUserId: userId)) + .frame(width: 14) + + Image(systemName: attack.wasAttacker(userId: userId) ? "arrow.right" : "arrow.left") + .font(.caption2) + .foregroundColor(attack.wasAttacker(userId: userId) ? .blue : .orange) + .frame(width: 12) + + Text(attack.opponentName(forUserId: userId)) + .font(.caption) + .lineLimit(1) + + Spacer() + + Text(attack.timeAgo) + .font(.caption2) + .foregroundColor(.secondary) + } + .contentShape(Rectangle()) } + .buttonStyle(.plain) } } else { Text("No recent attacks") diff --git a/MacTorn/MacTorn/Views/Components/ChainView.swift b/MacTorn/MacTorn/Views/Components/ChainView.swift index 9e35425..dc6541d 100644 --- a/MacTorn/MacTorn/Views/Components/ChainView.swift +++ b/MacTorn/MacTorn/Views/Components/ChainView.swift @@ -2,26 +2,32 @@ import SwiftUI struct ChainView: View { let chain: Chain - + let fetchTime: Date + var body: some View { if chain.isActive { - VStack(alignment: .leading, spacing: 4) { - HStack { - Image(systemName: "link") - .foregroundColor(timeoutColor) - Text("Chain: \(chain.current ?? 0)/\(chain.maximum ?? 0)") - .font(.caption.bold()) - - Spacer() - - Text(formatTime(chain.timeoutRemaining)) - .font(.caption.monospacedDigit()) - .foregroundColor(timeoutColor) + TimelineView(.periodic(from: fetchTime, by: 1.0)) { context in + let remaining = max(0, (chain.timeout ?? 0) - Int(context.date.timeIntervalSince1970)) + let color = timeoutColor(for: remaining) + + VStack(alignment: .leading, spacing: 4) { + HStack { + Image(systemName: "link") + .foregroundColor(color) + Text("Chain: \(chain.current ?? 0)/\(chain.maximum ?? 0)") + .font(.caption.bold()) + + Spacer() + + Text(formatTime(remaining)) + .font(.caption.monospacedDigit()) + .foregroundColor(color) + } } + .padding(8) + .background(color.opacity(0.1)) + .cornerRadius(8) } - .padding(8) - .background(timeoutColor.opacity(0.1)) - .cornerRadius(8) } else if chain.isOnCooldown { HStack { Image(systemName: "clock") @@ -32,16 +38,16 @@ struct ChainView: View { } } } - - private var timeoutColor: Color { - if chain.timeoutRemaining < 60 { + + private func timeoutColor(for remaining: Int) -> Color { + if remaining < 60 { return .red - } else if chain.timeoutRemaining < 180 { + } else if remaining < 180 { return .orange } return .green } - + private func formatTime(_ seconds: Int) -> String { let mins = seconds / 60 let secs = seconds % 60 diff --git a/MacTorn/MacTorn/Views/ContentView.swift b/MacTorn/MacTorn/Views/ContentView.swift index 974cf31..65c68c3 100644 --- a/MacTorn/MacTorn/Views/ContentView.swift +++ b/MacTorn/MacTorn/Views/ContentView.swift @@ -2,14 +2,16 @@ import SwiftUI enum AppTab: String, CaseIterable { case status = "Status" + case travel = "Travel" case money = "Money" case attacks = "Attacks" case faction = "Faction" case watchlist = "Watchlist" - + var icon: String { switch self { case .status: return "chart.bar.fill" + case .travel: return "airplane" case .money: return "dollarsign.circle.fill" case .attacks: return "bolt.shield.fill" case .faction: return "person.3.fill" @@ -123,6 +125,9 @@ struct ContentView: View { case .status: StatusView() .environmentObject(appState) + case .travel: + TravelView() + .environmentObject(appState) case .money: MoneyView() .environmentObject(appState) diff --git a/MacTorn/MacTorn/Views/StatusView.swift b/MacTorn/MacTorn/Views/StatusView.swift index 9697e3b..76af9ee 100644 --- a/MacTorn/MacTorn/Views/StatusView.swift +++ b/MacTorn/MacTorn/Views/StatusView.swift @@ -20,8 +20,8 @@ struct StatusView: View { } // Chain status - if let chain = appState.data?.chain { - ChainView(chain: chain) + if let chain = appState.data?.chain, let fetchTime = appState.lastUpdated { + ChainView(chain: chain, fetchTime: fetchTime) } // Travel status @@ -142,7 +142,7 @@ struct StatusView: View { Text("Arriving in:") .font(.caption2) .foregroundColor(.secondary) - Text(formatTime(travel.timeLeft ?? 0)) + Text(formatTime(appState.travelSecondsRemaining)) .font(.caption.monospacedDigit()) .foregroundColor(.blue) } diff --git a/MacTorn/MacTorn/Views/TravelView.swift b/MacTorn/MacTorn/Views/TravelView.swift new file mode 100644 index 0000000..0e68642 --- /dev/null +++ b/MacTorn/MacTorn/Views/TravelView.swift @@ -0,0 +1,350 @@ +import SwiftUI +import AppKit + +// MARK: - Flying Status View (separate for proper live updates) +struct FlyingStatusView: View { + @EnvironmentObject var appState: AppState + let destination: String + let timestamp: Int + let departed: Int + + private var secondsRemaining: Int { + appState.travelSecondsRemaining + } + + private var progress: Double { + let totalDuration = timestamp - departed + guard totalDuration > 0 else { return 0 } + return min(1.0, max(0.0, Double(totalDuration - secondsRemaining) / Double(totalDuration))) + } + + private func formatTime(_ seconds: Int) -> String { + if seconds <= 0 { return "Arrived!" } + let hours = seconds / 3600 + let minutes = (seconds % 3600) / 60 + let secs = seconds % 60 + if hours > 0 { + return String(format: "%d:%02d:%02d", hours, minutes, secs) + } + return String(format: "%d:%02d", minutes, secs) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "airplane") + .font(.title2) + .foregroundColor(.blue) + VStack(alignment: .leading, spacing: 2) { + Text("Flying to \(destination)") + .font(.headline) + Text("In transit...") + .font(.caption) + .foregroundColor(.secondary) + } + } + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Arriving in:") + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + Text(formatTime(secondsRemaining)) + .font(.title2.monospacedDigit()) + .fontWeight(.semibold) + .foregroundColor(.blue) + } + + // Progress bar + GeometryReader { geometry in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 4) + .fill(Color.gray.opacity(0.2)) + .frame(height: 8) + + RoundedRectangle(cornerRadius: 4) + .fill(Color.blue) + .frame(width: geometry.size.width * progress, height: 8) + } + } + .frame(height: 8) + } + } + .padding() + .background(Color.blue.opacity(0.1)) + .cornerRadius(12) + } +} + +struct TravelView: View { + @EnvironmentObject var appState: AppState + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + // Travel Status Section + travelStatusSection + + Divider() + + // Quick Travel Section (only when not traveling) + if let travel = appState.data?.travel, !travel.isTraveling { + quickTravelSection(isAbroad: travel.isAbroad, currentLocation: travel.destination) + Divider() + } + + // Pre-Arrival Alerts Section + preArrivalAlertsSection + + // Quick Actions + quickActionsSection + } + .padding() + } + .fixedSize(horizontal: false, vertical: true) + } + + // MARK: - Travel Status Section + @ViewBuilder + private var travelStatusSection: some View { + if let travel = appState.data?.travel { + if travel.isTraveling { + // Flying state with live countdown - FlyingStatusView observes appState directly + FlyingStatusView( + destination: travel.destination ?? "Unknown", + timestamp: travel.timestamp ?? 0, + departed: travel.departed ?? 0 + ) + } else if travel.isAbroad { + // Abroad state + abroadStatusView(travel) + } else { + // In Torn City + inTornStatusView + } + } else { + // No travel data + inTornStatusView + } + } + + private func abroadStatusView(_ travel: Travel) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "globe") + .font(.title2) + .foregroundColor(.orange) + VStack(alignment: .leading, spacing: 2) { + Text("In \(travel.destination ?? "Unknown")") + .font(.headline) + Text("Currently abroad") + .font(.caption) + .foregroundColor(.secondary) + } + } + + Button { + if let url = URL(string: "https://www.torn.com/travelagency.php") { + NSWorkspace.shared.open(url) + } + } label: { + HStack { + Image(systemName: "airplane.departure") + Text("Return to Torn") + } + .font(.subheadline.bold()) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(Color.orange) + .foregroundColor(.white) + .cornerRadius(8) + } + .buttonStyle(.plain) + } + .padding() + .background(Color.orange.opacity(0.1)) + .cornerRadius(12) + } + + private var inTornStatusView: some View { + HStack { + Image(systemName: "house.fill") + .font(.title2) + .foregroundColor(.green) + VStack(alignment: .leading, spacing: 2) { + Text("In Torn City") + .font(.headline) + Text("Ready to travel") + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + } + .padding() + .background(Color.green.opacity(0.1)) + .cornerRadius(12) + } + + // MARK: - Quick Travel Section + private func quickTravelSection(isAbroad: Bool, currentLocation: String?) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "map.fill") + .foregroundColor(.secondary) + Text("Quick Travel") + .font(.subheadline.bold()) + .foregroundColor(.secondary) + } + + if isAbroad { + // Show only return button when abroad + Button { + if let url = URL(string: "https://www.torn.com/travelagency.php") { + NSWorkspace.shared.open(url) + } + } label: { + HStack { + Text("Torn") + Text("Return Home") + .font(.caption) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(Color.accentColor.opacity(0.1)) + .cornerRadius(8) + } + .buttonStyle(.plain) + } else { + // Show all destinations grid + LazyVGrid(columns: [ + GridItem(.flexible()), + GridItem(.flexible()) + ], spacing: 8) { + ForEach(TornDestination.allCases) { destination in + destinationButton(destination) + } + } + } + } + } + + private func destinationButton(_ destination: TornDestination) -> some View { + Button { + NSWorkspace.shared.open(destination.travelAgencyURL) + } label: { + VStack(spacing: 4) { + HStack(spacing: 4) { + Text(destination.flag) + .font(.title3) + Text(destination.rawValue) + .font(.caption) + .lineLimit(1) + } + Text("~\(destination.flightTimeFormatted)") + .font(.caption2) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(Color.accentColor.opacity(0.1)) + .cornerRadius(8) + } + .buttonStyle(.plain) + } + + // MARK: - Pre-Arrival Alerts Section + private var preArrivalAlertsSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "bell.fill") + .foregroundColor(.secondary) + Text("Pre-Arrival Alerts") + .font(.subheadline.bold()) + .foregroundColor(.secondary) + } + + VStack(spacing: 8) { + ForEach(appState.travelNotificationSettings) { setting in + Toggle(isOn: Binding( + get: { setting.enabled }, + set: { newValue in + var updated = setting + updated.enabled = newValue + appState.updateTravelNotificationSetting(updated) + } + )) { + Text(setting.displayName) + .font(.subheadline) + } + .toggleStyle(.switch) + .controlSize(.small) + } + } + .padding() + .background(Color.secondary.opacity(0.1)) + .cornerRadius(8) + } + } + + // MARK: - Quick Actions Section + private var quickActionsSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "link") + .foregroundColor(.secondary) + Text("Quick Actions") + .font(.subheadline.bold()) + .foregroundColor(.secondary) + } + + HStack(spacing: 8) { + Button { + if let url = URL(string: "https://www.torn.com/travelagency.php") { + NSWorkspace.shared.open(url) + } + } label: { + HStack { + Image(systemName: "airplane.departure") + Text("Travel Agency") + } + .font(.caption) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(Color.accentColor.opacity(0.1)) + .cornerRadius(6) + } + .buttonStyle(.plain) + + Button { + if let url = URL(string: "https://www.torn.com/page.php?sid=ItemMarket") { + NSWorkspace.shared.open(url) + } + } label: { + HStack { + Image(systemName: "storefront") + Text("Abroad") + } + .font(.caption) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(Color.accentColor.opacity(0.1)) + .cornerRadius(6) + } + .buttonStyle(.plain) + } + } + } + + // MARK: - Helpers + private func formatTime(_ seconds: Int) -> String { + if seconds <= 0 { return "Arrived!" } + let hours = seconds / 3600 + let minutes = (seconds % 3600) / 60 + let secs = seconds % 60 + if hours > 0 { + return String(format: "%d:%02d:%02d", hours, minutes, secs) + } + return String(format: "%d:%02d", minutes, secs) + } +} diff --git a/README.md b/README.md index 2fef0ae..8e612ae 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,13 @@ A native macOS menu bar app for monitoring your **Torn** game status. - Events feed - 8 customizable quick links +### ✈️ Travel Tab +- **Live countdown timer** in menu bar during flight (βœˆοΈπŸ‡ΊπŸ‡Έ 5:32) +- Flight status with progress bar +- Quick travel destination picker (all 11 Torn destinations) +- Pre-arrival notifications (configurable: 2min, 1min, 30sec, 10sec) +- Country flags for all destinations + ### πŸ’° Money Tab - Cash, Vault, Points, Tokens display - Quick actions: Send Money, Bazaar, Bank