mirror of
https://github.com/pawelorzech/MacTorn.git
synced 2026-01-30 04:04:27 +00:00
feat: Add Travel tab with live countdown timer and pre-arrival notifications
- 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
This commit is contained in:
parent
2e485c05d1
commit
71db6d4db1
11 changed files with 819 additions and 84 deletions
|
|
@ -28,6 +28,7 @@
|
||||||
AAA00019 /* WatchlistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10020 /* WatchlistView.swift */; };
|
AAA00019 /* WatchlistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10020 /* WatchlistView.swift */; };
|
||||||
AAA00020 /* PropertiesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10021 /* PropertiesView.swift */; };
|
AAA00020 /* PropertiesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10021 /* PropertiesView.swift */; };
|
||||||
AAA00021 /* NetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10022 /* NetworkSession.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 */
|
/* Unit Tests */
|
||||||
BBB00001 /* MockNetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10001 /* MockNetworkSession.swift */; };
|
BBB00001 /* MockNetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10001 /* MockNetworkSession.swift */; };
|
||||||
BBB00002 /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10002 /* TestHelpers.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 = "<group>"; };
|
AAA10020 /* WatchlistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchlistView.swift; sourceTree = "<group>"; };
|
||||||
AAA10021 /* PropertiesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropertiesView.swift; sourceTree = "<group>"; };
|
AAA10021 /* PropertiesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropertiesView.swift; sourceTree = "<group>"; };
|
||||||
AAA10022 /* NetworkSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkSession.swift; sourceTree = "<group>"; };
|
AAA10022 /* NetworkSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkSession.swift; sourceTree = "<group>"; };
|
||||||
|
AAA10023 /* TravelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TravelView.swift; sourceTree = "<group>"; };
|
||||||
AAA10000 /* MacTorn.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacTorn.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
AAA10000 /* MacTorn.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacTorn.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* Unit Test Files */
|
/* Unit Test Files */
|
||||||
BBB10001 /* MockNetworkSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNetworkSession.swift; sourceTree = "<group>"; };
|
BBB10001 /* MockNetworkSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNetworkSession.swift; sourceTree = "<group>"; };
|
||||||
|
|
@ -187,6 +189,7 @@
|
||||||
AAA10002 /* ContentView.swift */,
|
AAA10002 /* ContentView.swift */,
|
||||||
AAA10006 /* SettingsView.swift */,
|
AAA10006 /* SettingsView.swift */,
|
||||||
AAA10007 /* StatusView.swift */,
|
AAA10007 /* StatusView.swift */,
|
||||||
|
AAA10023 /* TravelView.swift */,
|
||||||
AAA10017 /* MoneyView.swift */,
|
AAA10017 /* MoneyView.swift */,
|
||||||
AAA10018 /* AttacksView.swift */,
|
AAA10018 /* AttacksView.swift */,
|
||||||
AAA10019 /* FactionView.swift */,
|
AAA10019 /* FactionView.swift */,
|
||||||
|
|
@ -437,6 +440,7 @@
|
||||||
AAA00019 /* WatchlistView.swift in Sources */,
|
AAA00019 /* WatchlistView.swift in Sources */,
|
||||||
AAA00020 /* PropertiesView.swift in Sources */,
|
AAA00020 /* PropertiesView.swift in Sources */,
|
||||||
AAA00021 /* NetworkSession.swift in Sources */,
|
AAA00021 /* NetworkSession.swift in Sources */,
|
||||||
|
AAA00022 /* TravelView.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|
@ -618,7 +622,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.app;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
|
@ -645,7 +649,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.app;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
|
@ -663,7 +667,7 @@
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
|
|
@ -681,7 +685,7 @@
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
|
|
@ -699,7 +703,7 @@
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornUITests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornUITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
|
|
@ -716,7 +720,7 @@
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornUITests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornUITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,28 @@ struct MacTornApp: App {
|
||||||
ContentView()
|
ContentView()
|
||||||
.environmentObject(appState)
|
.environmentObject(appState)
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: menuBarIcon)
|
MenuBarLabel(appState: appState)
|
||||||
.renderingMode(.template)
|
|
||||||
}
|
}
|
||||||
.menuBarExtraStyle(.window)
|
.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 {
|
private var menuBarIcon: String {
|
||||||
// Error state
|
// Error state
|
||||||
|
|
@ -21,11 +38,6 @@ struct MacTornApp: App {
|
||||||
return "exclamationmark.triangle.fill"
|
return "exclamationmark.triangle.fill"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Traveling state
|
|
||||||
if let travel = appState.data?.travel, travel.isTraveling {
|
|
||||||
return "airplane"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Abroad state
|
// Abroad state
|
||||||
if let travel = appState.data?.travel, travel.isAbroad {
|
if let travel = appState.data?.travel, travel.isAbroad {
|
||||||
return "globe"
|
return "globe"
|
||||||
|
|
@ -41,4 +53,33 @@ struct MacTornApp: App {
|
||||||
// Default
|
// Default
|
||||||
return "bolt"
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,107 @@ struct Travel: Codable, Equatable {
|
||||||
guard isTraveling, let ts = timestamp else { return nil }
|
guard isTraveling, let ts = timestamp else { return nil }
|
||||||
return Date(timeIntervalSince1970: TimeInterval(ts))
|
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)
|
// MARK: - Status (Hospital/Jail)
|
||||||
|
|
@ -258,8 +359,10 @@ struct AttackResult: Codable, Identifiable {
|
||||||
let code: String?
|
let code: String?
|
||||||
let timestampStarted: Int?
|
let timestampStarted: Int?
|
||||||
let timestampEnded: Int?
|
let timestampEnded: Int?
|
||||||
let opponentId: Int?
|
let attackerId: Int?
|
||||||
let opponentName: String?
|
let attackerName: String?
|
||||||
|
let defenderId: Int?
|
||||||
|
let defenderName: String?
|
||||||
let result: String?
|
let result: String?
|
||||||
let respect: Double?
|
let respect: Double?
|
||||||
|
|
||||||
|
|
@ -269,28 +372,68 @@ struct AttackResult: Codable, Identifiable {
|
||||||
case code
|
case code
|
||||||
case timestampStarted = "timestamp_started"
|
case timestampStarted = "timestamp_started"
|
||||||
case timestampEnded = "timestamp_ended"
|
case timestampEnded = "timestamp_ended"
|
||||||
case opponentId = "defender_id"
|
case attackerId = "attacker_id"
|
||||||
case opponentName = "defender_name"
|
case attackerName = "attacker_name"
|
||||||
|
case defenderId = "defender_id"
|
||||||
|
case defenderName = "defender_name"
|
||||||
case result, respect
|
case result, respect
|
||||||
}
|
}
|
||||||
|
|
||||||
var resultIcon: String {
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func resultIcon(forUserId userId: Int) -> String {
|
||||||
|
let userWasAttacker = wasAttacker(userId: userId)
|
||||||
switch result {
|
switch result {
|
||||||
case "Attacked": return "checkmark.circle.fill"
|
case "Attacked": return userWasAttacker ? "checkmark.circle.fill" : "xmark.circle.fill"
|
||||||
case "Mugged": return "dollarsign.circle.fill"
|
case "Mugged": return userWasAttacker ? "dollarsign.circle.fill" : "xmark.circle.fill"
|
||||||
case "Hospitalized": return "cross.circle.fill"
|
case "Hospitalized": return userWasAttacker ? "cross.circle.fill" : "xmark.circle.fill"
|
||||||
case "Lost": return "xmark.circle.fill"
|
case "Lost": return userWasAttacker ? "xmark.circle.fill" : "shield.checkered"
|
||||||
case "Stalemate": return "equal.circle.fill"
|
case "Stalemate": return "equal.circle.fill"
|
||||||
|
case "Escape": return userWasAttacker ? "figure.run" : "shield.checkered"
|
||||||
|
case "Assist": return "person.2.fill"
|
||||||
default: return "questionmark.circle"
|
default: return "questionmark.circle"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var resultColor: Color {
|
func resultColor(forUserId userId: Int) -> Color {
|
||||||
|
let userWasAttacker = wasAttacker(userId: userId)
|
||||||
switch result {
|
switch result {
|
||||||
case "Attacked", "Mugged", "Hospitalized": return .green
|
case "Attacked", "Mugged", "Hospitalized":
|
||||||
case "Lost": return .red
|
return userWasAttacker ? .green : .red
|
||||||
case "Stalemate": return .orange
|
case "Lost":
|
||||||
default: return .gray
|
return userWasAttacker ? .red : .green
|
||||||
|
case "Stalemate":
|
||||||
|
return .orange
|
||||||
|
case "Escape":
|
||||||
|
return userWasAttacker ? .orange : .green
|
||||||
|
case "Assist":
|
||||||
|
return .blue
|
||||||
|
default:
|
||||||
|
return .gray
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,13 @@ enum NotificationType: String {
|
||||||
case nerve
|
case nerve
|
||||||
case happy
|
case happy
|
||||||
case life
|
case life
|
||||||
|
case travelApproaching
|
||||||
|
|
||||||
var url: URL {
|
var url: URL {
|
||||||
switch self {
|
switch self {
|
||||||
case .drugReady, .medicalReady, .boosterReady:
|
case .drugReady, .medicalReady, .boosterReady:
|
||||||
return URL(string: "https://www.torn.com/item.php")!
|
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")!
|
return URL(string: "https://www.torn.com/page.php?sid=ItemMarket")!
|
||||||
case .chainExpiring:
|
case .chainExpiring:
|
||||||
return URL(string: "https://www.torn.com/factions.php?step=your#/tab=wars")!
|
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
|
// MARK: - UNUserNotificationCenterDelegate
|
||||||
|
|
||||||
func userNotificationCenter(
|
func userNotificationCenter(
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ class AppState: ObservableObject {
|
||||||
@Published var errorMsg: String?
|
@Published var errorMsg: String?
|
||||||
@Published var isLoading: Bool = false
|
@Published var isLoading: Bool = false
|
||||||
@Published var notificationRules: [NotificationRule] = []
|
@Published var notificationRules: [NotificationRule] = []
|
||||||
|
@Published var travelNotificationSettings: [TravelNotificationSetting] = []
|
||||||
|
|
||||||
// MARK: - New Data Sources
|
// MARK: - New Data Sources
|
||||||
@Published var moneyData: MoneyData?
|
@Published var moneyData: MoneyData?
|
||||||
|
|
@ -29,6 +30,13 @@ class AppState: ObservableObject {
|
||||||
// MARK: - Update State
|
// MARK: - Update State
|
||||||
@Published var updateAvailable: GitHubRelease?
|
@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
|
// MARK: - Managers
|
||||||
let launchAtLogin = LaunchAtLoginManager()
|
let launchAtLogin = LaunchAtLoginManager()
|
||||||
let shortcutsManager = ShortcutsManager()
|
let shortcutsManager = ShortcutsManager()
|
||||||
|
|
@ -50,6 +58,7 @@ class AppState: ObservableObject {
|
||||||
init(session: NetworkSession = URLSession.shared) {
|
init(session: NetworkSession = URLSession.shared) {
|
||||||
self.session = session
|
self.session = session
|
||||||
loadNotificationRules()
|
loadNotificationRules()
|
||||||
|
loadTravelNotificationSettings()
|
||||||
loadWatchlist()
|
loadWatchlist()
|
||||||
// Polling and permissions moved to onAppear in UI
|
// Polling and permissions moved to onAppear in UI
|
||||||
}
|
}
|
||||||
|
|
@ -78,6 +87,102 @@ class AppState: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// MARK: - Watchlist
|
||||||
func loadWatchlist() {
|
func loadWatchlist() {
|
||||||
if let data = UserDefaults.standard.data(forKey: "watchlist"),
|
if let data = UserDefaults.standard.data(forKey: "watchlist"),
|
||||||
|
|
@ -379,8 +484,10 @@ class AppState: ObservableObject {
|
||||||
code: code,
|
code: code,
|
||||||
timestampStarted: attackDict["timestamp_started"] as? Int,
|
timestampStarted: attackDict["timestamp_started"] as? Int,
|
||||||
timestampEnded: attackDict["timestamp_ended"] as? Int,
|
timestampEnded: attackDict["timestamp_ended"] as? Int,
|
||||||
opponentId: attackDict["defender_id"] as? Int,
|
attackerId: attackDict["attacker_id"] as? Int,
|
||||||
opponentName: attackDict["defender_name"] as? String,
|
attackerName: attackDict["attacker_name"] as? String,
|
||||||
|
defenderId: attackDict["defender_id"] as? Int,
|
||||||
|
defenderName: attackDict["defender_name"] as? String,
|
||||||
result: attackDict["result"] as? String,
|
result: attackDict["result"] as? String,
|
||||||
respect: attackDict["respect"] as? Double
|
respect: attackDict["respect"] as? Double
|
||||||
)
|
)
|
||||||
|
|
@ -441,8 +548,12 @@ class AppState: ObservableObject {
|
||||||
if let p = result.4 { self.propertiesData = p }
|
if let p = result.4 { self.propertiesData = p }
|
||||||
|
|
||||||
self.lastUpdated = Date()
|
self.lastUpdated = Date()
|
||||||
|
self.lastFetchTime = Date()
|
||||||
self.errorMsg = nil
|
self.errorMsg = nil
|
||||||
|
|
||||||
|
// Manage travel timer after data is set
|
||||||
|
self.manageTravelTimer()
|
||||||
|
|
||||||
// Force UI update by triggering objectWillChange
|
// Force UI update by triggering objectWillChange
|
||||||
self.objectWillChange.send()
|
self.objectWillChange.send()
|
||||||
logger.info("UI update triggered, lastUpdated: \(self.lastUpdated?.description ?? "nil")")
|
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 {
|
if let prevTravel = previousTravel, let currentTravel = newData.travel {
|
||||||
|
// Just landed
|
||||||
if prevTravel.isTraveling && !currentTravel.isTraveling {
|
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 {
|
if let chain = newData.chain, chain.isActive {
|
||||||
|
|
|
||||||
|
|
@ -51,14 +51,26 @@ struct AttacksView: View {
|
||||||
.font(.caption.bold())
|
.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
|
ForEach(attacks.prefix(5)) { attack in
|
||||||
HStack {
|
Button {
|
||||||
Image(systemName: attack.resultIcon)
|
if let opponentId = attack.opponentId(forUserId: userId),
|
||||||
.foregroundColor(attack.resultColor)
|
let url = URL(string: "https://www.torn.com/profiles.php?XID=\(opponentId)") {
|
||||||
.frame(width: 16)
|
NSWorkspace.shared.open(url)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: attack.resultIcon(forUserId: userId))
|
||||||
|
.foregroundColor(attack.resultColor(forUserId: userId))
|
||||||
|
.frame(width: 14)
|
||||||
|
|
||||||
Text(attack.opponentName ?? "Unknown")
|
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)
|
.font(.caption)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|
||||||
|
|
@ -68,6 +80,9 @@ struct AttacksView: View {
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("No recent attacks")
|
Text("No recent attacks")
|
||||||
|
|
|
||||||
|
|
@ -2,26 +2,32 @@ import SwiftUI
|
||||||
|
|
||||||
struct ChainView: View {
|
struct ChainView: View {
|
||||||
let chain: Chain
|
let chain: Chain
|
||||||
|
let fetchTime: Date
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if chain.isActive {
|
if chain.isActive {
|
||||||
|
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) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "link")
|
Image(systemName: "link")
|
||||||
.foregroundColor(timeoutColor)
|
.foregroundColor(color)
|
||||||
Text("Chain: \(chain.current ?? 0)/\(chain.maximum ?? 0)")
|
Text("Chain: \(chain.current ?? 0)/\(chain.maximum ?? 0)")
|
||||||
.font(.caption.bold())
|
.font(.caption.bold())
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Text(formatTime(chain.timeoutRemaining))
|
Text(formatTime(remaining))
|
||||||
.font(.caption.monospacedDigit())
|
.font(.caption.monospacedDigit())
|
||||||
.foregroundColor(timeoutColor)
|
.foregroundColor(color)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(8)
|
.padding(8)
|
||||||
.background(timeoutColor.opacity(0.1))
|
.background(color.opacity(0.1))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
} else if chain.isOnCooldown {
|
} else if chain.isOnCooldown {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "clock")
|
Image(systemName: "clock")
|
||||||
|
|
@ -33,10 +39,10 @@ struct ChainView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var timeoutColor: Color {
|
private func timeoutColor(for remaining: Int) -> Color {
|
||||||
if chain.timeoutRemaining < 60 {
|
if remaining < 60 {
|
||||||
return .red
|
return .red
|
||||||
} else if chain.timeoutRemaining < 180 {
|
} else if remaining < 180 {
|
||||||
return .orange
|
return .orange
|
||||||
}
|
}
|
||||||
return .green
|
return .green
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import SwiftUI
|
||||||
|
|
||||||
enum AppTab: String, CaseIterable {
|
enum AppTab: String, CaseIterable {
|
||||||
case status = "Status"
|
case status = "Status"
|
||||||
|
case travel = "Travel"
|
||||||
case money = "Money"
|
case money = "Money"
|
||||||
case attacks = "Attacks"
|
case attacks = "Attacks"
|
||||||
case faction = "Faction"
|
case faction = "Faction"
|
||||||
|
|
@ -10,6 +11,7 @@ enum AppTab: String, CaseIterable {
|
||||||
var icon: String {
|
var icon: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .status: return "chart.bar.fill"
|
case .status: return "chart.bar.fill"
|
||||||
|
case .travel: return "airplane"
|
||||||
case .money: return "dollarsign.circle.fill"
|
case .money: return "dollarsign.circle.fill"
|
||||||
case .attacks: return "bolt.shield.fill"
|
case .attacks: return "bolt.shield.fill"
|
||||||
case .faction: return "person.3.fill"
|
case .faction: return "person.3.fill"
|
||||||
|
|
@ -123,6 +125,9 @@ struct ContentView: View {
|
||||||
case .status:
|
case .status:
|
||||||
StatusView()
|
StatusView()
|
||||||
.environmentObject(appState)
|
.environmentObject(appState)
|
||||||
|
case .travel:
|
||||||
|
TravelView()
|
||||||
|
.environmentObject(appState)
|
||||||
case .money:
|
case .money:
|
||||||
MoneyView()
|
MoneyView()
|
||||||
.environmentObject(appState)
|
.environmentObject(appState)
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ struct StatusView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chain status
|
// Chain status
|
||||||
if let chain = appState.data?.chain {
|
if let chain = appState.data?.chain, let fetchTime = appState.lastUpdated {
|
||||||
ChainView(chain: chain)
|
ChainView(chain: chain, fetchTime: fetchTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Travel status
|
// Travel status
|
||||||
|
|
@ -142,7 +142,7 @@ struct StatusView: View {
|
||||||
Text("Arriving in:")
|
Text("Arriving in:")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
Text(formatTime(travel.timeLeft ?? 0))
|
Text(formatTime(appState.travelSecondsRemaining))
|
||||||
.font(.caption.monospacedDigit())
|
.font(.caption.monospacedDigit())
|
||||||
.foregroundColor(.blue)
|
.foregroundColor(.blue)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
350
MacTorn/MacTorn/Views/TravelView.swift
Normal file
350
MacTorn/MacTorn/Views/TravelView.swift
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,13 @@ A native macOS menu bar app for monitoring your **Torn** game status.
|
||||||
- Events feed
|
- Events feed
|
||||||
- 8 customizable quick links
|
- 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
|
### 💰 Money Tab
|
||||||
- Cash, Vault, Points, Tokens display
|
- Cash, Vault, Points, Tokens display
|
||||||
- Quick actions: Send Money, Bazaar, Bank
|
- Quick actions: Send Money, Bazaar, Bank
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue