Add new views and models for money, attacks, faction, properties, and watchlist

Introduces MoneyView, AttacksView, FactionView, PropertiesView, and WatchlistView with corresponding models and state in AppState. Updates ContentView to use a tabbed interface for navigation between new sections. Extends TornModels.swift with new data structures for money, battle stats, attacks, faction, properties, and watchlist items. Adds logic in AppState for fetching, parsing, and managing new data, including watchlist price fetching. Updates project file to include new views.
This commit is contained in:
Paweł Orzech 2026-01-17 21:10:57 +00:00
parent 7f947b635c
commit e75131aa67
No known key found for this signature in database
11 changed files with 1540 additions and 98 deletions

View file

@ -22,6 +22,11 @@
AAA00013 /* StatusBadgesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10014 /* StatusBadgesView.swift */; };
AAA00014 /* EventsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10015 /* EventsView.swift */; };
AAA00015 /* SoundManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10016 /* SoundManager.swift */; };
AAA00016 /* MoneyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10017 /* MoneyView.swift */; };
AAA00017 /* AttacksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10018 /* AttacksView.swift */; };
AAA00018 /* FactionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10019 /* FactionView.swift */; };
AAA00019 /* WatchlistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10020 /* WatchlistView.swift */; };
AAA00020 /* PropertiesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10021 /* PropertiesView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@ -41,6 +46,11 @@
AAA10014 /* StatusBadgesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBadgesView.swift; sourceTree = "<group>"; };
AAA10015 /* EventsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventsView.swift; sourceTree = "<group>"; };
AAA10016 /* SoundManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundManager.swift; sourceTree = "<group>"; };
AAA10017 /* MoneyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoneyView.swift; sourceTree = "<group>"; };
AAA10018 /* AttacksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttacksView.swift; sourceTree = "<group>"; };
AAA10019 /* FactionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactionView.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>"; };
AAA10000 /* MacTorn.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacTorn.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
@ -107,6 +117,11 @@
AAA10002 /* ContentView.swift */,
AAA10006 /* SettingsView.swift */,
AAA10007 /* StatusView.swift */,
AAA10017 /* MoneyView.swift */,
AAA10018 /* AttacksView.swift */,
AAA10019 /* FactionView.swift */,
AAA10020 /* WatchlistView.swift */,
AAA10021 /* PropertiesView.swift */,
AAA30006 /* Components */,
);
path = Views;
@ -217,6 +232,11 @@
AAA00013 /* StatusBadgesView.swift in Sources */,
AAA00014 /* EventsView.swift in Sources */,
AAA00015 /* SoundManager.swift in Sources */,
AAA00016 /* MoneyView.swift in Sources */,
AAA00017 /* AttacksView.swift in Sources */,
AAA00018 /* FactionView.swift in Sources */,
AAA00019 /* WatchlistView.swift in Sources */,
AAA00020 /* PropertiesView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View file

@ -1,4 +1,5 @@
import Foundation
import SwiftUI
// MARK: - Root Response
struct TornResponse: Codable {
@ -188,6 +189,257 @@ struct TornMessage: Codable {
let read: Int?
}
// MARK: - Money
struct MoneyData: Codable {
let cash: Int
let vault: Int
let points: Int
let tokens: Int
let cayman: Int
enum CodingKeys: String, CodingKey {
case cash = "money_onhand"
case vault = "vault_amount"
case points
case tokens = "company_funds"
case cayman = "cayman_bank"
}
init(cash: Int = 0, vault: Int = 0, points: Int = 0, tokens: Int = 0, cayman: Int = 0) {
self.cash = cash
self.vault = vault
self.points = points
self.tokens = tokens
self.cayman = cayman
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
cash = (try? container.decode(Int.self, forKey: .cash)) ?? 0
vault = (try? container.decode(Int.self, forKey: .vault)) ?? 0
points = (try? container.decode(Int.self, forKey: .points)) ?? 0
tokens = (try? container.decode(Int.self, forKey: .tokens)) ?? 0
cayman = (try? container.decode(Int.self, forKey: .cayman)) ?? 0
}
}
// MARK: - Battle Stats
struct BattleStats: Codable {
let strength: Int
let defense: Int
let speed: Int
let dexterity: Int
let total: Int
init(strength: Int = 0, defense: Int = 0, speed: Int = 0, dexterity: Int = 0) {
self.strength = strength
self.defense = defense
self.speed = speed
self.dexterity = dexterity
self.total = strength + defense + speed + dexterity
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
strength = (try? container.decode(Int.self, forKey: .strength)) ?? 0
defense = (try? container.decode(Int.self, forKey: .defense)) ?? 0
speed = (try? container.decode(Int.self, forKey: .speed)) ?? 0
dexterity = (try? container.decode(Int.self, forKey: .dexterity)) ?? 0
total = (try? container.decode(Int.self, forKey: .total)) ?? (strength + defense + speed + dexterity)
}
enum CodingKeys: String, CodingKey {
case strength, defense, speed, dexterity, total
}
}
// MARK: - Attack Result
struct AttackResult: Codable, Identifiable {
let code: String?
let timestampStarted: Int?
let timestampEnded: Int?
let opponentId: Int?
let opponentName: 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 result, respect
}
var resultIcon: String {
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 "Stalemate": return "equal.circle.fill"
default: return "questionmark.circle"
}
}
var resultColor: Color {
switch result {
case "Attacked", "Mugged", "Hospitalized": return .green
case "Lost": return .red
case "Stalemate": return .orange
default: return .gray
}
}
var timeAgo: String {
guard let ts = timestampEnded else { return "" }
let now = Int(Date().timeIntervalSince1970)
let diff = now - ts
if diff < 3600 { return "\(diff / 60)m" }
if diff < 86400 { return "\(diff / 3600)h" }
return "\(diff / 86400)d"
}
}
// MARK: - Faction Data
struct FactionData: Codable {
let name: String
let factionId: Int
let respect: Int
let chain: FactionChain
enum CodingKeys: String, CodingKey {
case name
case factionId = "ID"
case respect
case chain
}
init(name: String = "", factionId: Int = 0, respect: Int = 0, chain: FactionChain = FactionChain()) {
self.name = name
self.factionId = factionId
self.respect = respect
self.chain = chain
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = (try? container.decode(String.self, forKey: .name)) ?? ""
factionId = (try? container.decode(Int.self, forKey: .factionId)) ?? 0
respect = (try? container.decode(Int.self, forKey: .respect)) ?? 0
chain = (try? container.decode(FactionChain.self, forKey: .chain)) ?? FactionChain()
}
}
struct FactionChain: Codable {
let current: Int
let max: Int
let timeout: Int
let cooldown: Int
init(current: Int = 0, max: Int = 0, timeout: Int = 0, cooldown: Int = 0) {
self.current = current
self.max = max
self.timeout = timeout
self.cooldown = cooldown
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
current = (try? container.decode(Int.self, forKey: .current)) ?? 0
max = (try? container.decode(Int.self, forKey: .max)) ?? 0
timeout = (try? container.decode(Int.self, forKey: .timeout)) ?? 0
cooldown = (try? container.decode(Int.self, forKey: .cooldown)) ?? 0
}
enum CodingKeys: String, CodingKey {
case current, max, timeout, cooldown
}
}
// MARK: - Property Info
struct PropertyInfo: Codable, Identifiable {
let id: Int
let propertyType: String
let vault: Int
let upkeep: Int
let rented: Bool
let daysUntilUpkeep: Int
enum CodingKeys: String, CodingKey {
case id = "property_id"
case propertyType = "property"
case vault = "money"
case upkeep, rented
case daysUntilUpkeep = "days_left"
}
init(id: Int = 0, propertyType: String = "", vault: Int = 0, upkeep: Int = 0, rented: Bool = false, daysUntilUpkeep: Int = 0) {
self.id = id
self.propertyType = propertyType
self.vault = vault
self.upkeep = upkeep
self.rented = rented
self.daysUntilUpkeep = daysUntilUpkeep
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try? container.decode(Int.self, forKey: .id)) ?? 0
propertyType = (try? container.decode(String.self, forKey: .propertyType)) ?? ""
vault = (try? container.decode(Int.self, forKey: .vault)) ?? 0
upkeep = (try? container.decode(Int.self, forKey: .upkeep)) ?? 0
rented = (try? container.decode(Bool.self, forKey: .rented)) ?? false
daysUntilUpkeep = (try? container.decode(Int.self, forKey: .daysUntilUpkeep)) ?? 0
}
}
// MARK: - Watchlist Item
struct WatchlistItem: Codable, Identifiable {
let id: Int
let name: String
var lowestPrice: Int
var lowestPriceQuantity: Int
var secondLowestPrice: Int
var lastUpdated: Date?
var error: String?
// Explicit memberwise initializer
init(id: Int, name: String, lowestPrice: Int, lowestPriceQuantity: Int, secondLowestPrice: Int, lastUpdated: Date?, error: String?) {
self.id = id
self.name = name
self.lowestPrice = lowestPrice
self.lowestPriceQuantity = lowestPriceQuantity
self.secondLowestPrice = secondLowestPrice
self.lastUpdated = lastUpdated
self.error = error
}
// Custom decoding to handle legacy data missing new fields
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(Int.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
lowestPrice = try container.decodeIfPresent(Int.self, forKey: .lowestPrice) ?? 0
lowestPriceQuantity = try container.decodeIfPresent(Int.self, forKey: .lowestPriceQuantity) ?? 0
secondLowestPrice = try container.decodeIfPresent(Int.self, forKey: .secondLowestPrice) ?? 0
lastUpdated = try container.decodeIfPresent(Date.self, forKey: .lastUpdated)
error = try container.decodeIfPresent(String.self, forKey: .error)
}
var priceDifference: Int {
guard secondLowestPrice > 0 && lowestPrice > 0 else { return 0 }
return secondLowestPrice - lowestPrice
}
var isLoading: Bool {
lowestPrice == 0 && error == nil
}
}
// MARK: - Error
struct TornError: Codable {
let code: Int
@ -197,11 +449,23 @@ struct TornError: Codable {
// MARK: - API Configuration
enum TornAPI {
static let baseURL = "https://api.torn.com/user/"
static let selections = "basic,bars,cooldowns,travel,profile,events,messages"
static let factionURL = "https://api.torn.com/faction/"
static let marketURL = "https://api.torn.com/market/"
static let tornURL = "https://api.torn.com/torn/"
static let selections = "basic,bars,cooldowns,travel,profile,events,messages,money,battlestats,attacks,properties"
static func url(for apiKey: String) -> URL? {
URL(string: "\(baseURL)?selections=\(selections)&key=\(apiKey)")
}
static func factionURL(for apiKey: String) -> URL? {
URL(string: "\(factionURL)?selections=basic,chain&key=\(apiKey)")
}
static func marketURL(itemId: Int, apiKey: String) -> URL? {
// v2 endpoint for item market
URL(string: "https://api.torn.com/v2/market/\(itemId)?selections=itemmarket,bazaar&key=\(apiKey)")
}
}
// MARK: - Notification Settings

View file

@ -15,6 +15,14 @@ class AppState: ObservableObject {
@Published var isLoading: Bool = false
@Published var notificationRules: [NotificationRule] = []
// MARK: - New Data Sources
@Published var moneyData: MoneyData?
@Published var battleStats: BattleStats?
@Published var recentAttacks: [AttackResult]?
@Published var factionData: FactionData?
@Published var propertiesData: [PropertyInfo]?
@Published var watchlistItems: [WatchlistItem] = []
// MARK: - Managers
let launchAtLogin = LaunchAtLoginManager()
let shortcutsManager = ShortcutsManager()
@ -31,12 +39,11 @@ class AppState: ObservableObject {
init() {
loadNotificationRules()
startPolling()
Task {
await NotificationManager.shared.requestPermission()
}
loadWatchlist()
// Polling and permissions moved to onAppear in UI
}
// MARK: - Notification Rules
func loadNotificationRules() {
if let data = UserDefaults.standard.data(forKey: "notificationRules"),
let rules = try? JSONDecoder().decode([NotificationRule].self, from: data) {
@ -60,14 +67,149 @@ class AppState: ObservableObject {
}
}
// MARK: - Watchlist
func loadWatchlist() {
if let data = UserDefaults.standard.data(forKey: "watchlist"),
let items = try? JSONDecoder().decode([WatchlistItem].self, from: data) {
watchlistItems = items
}
}
func saveWatchlist() {
if let data = try? JSONEncoder().encode(watchlistItems) {
UserDefaults.standard.set(data, forKey: "watchlist")
}
}
func addToWatchlist(itemId: Int, name: String) {
let item = WatchlistItem(id: itemId, name: name, lowestPrice: 0, lowestPriceQuantity: 0, secondLowestPrice: 0, lastUpdated: nil, error: nil)
if !watchlistItems.contains(where: { $0.id == itemId }) {
watchlistItems.append(item)
saveWatchlist()
// Fetch price immediately
Task {
await fetchItemPrice(itemId: itemId)
}
}
}
func removeFromWatchlist(_ itemId: Int) {
watchlistItems.removeAll { $0.id == itemId }
saveWatchlist()
}
func refreshWatchlistPrices() {
Task {
await fetchWatchlistPrices()
}
}
private func fetchWatchlistPrices() async {
for item in watchlistItems {
await fetchItemPrice(itemId: item.id)
}
}
private func fetchItemPrice(itemId: Int) async {
guard !apiKey.isEmpty,
let url = TornAPI.marketURL(itemId: itemId, apiKey: apiKey) else { return }
// Debug
// print("Fetching price for item \(itemId): \(url.absoluteString)")
do {
let (data, response) = try await URLSession.shared.data(from: url)
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 {
// print("HTTP Error: \(httpResponse.statusCode)")
await updateItemError(itemId: itemId, error: "HTTP \(httpResponse.statusCode)")
return
}
// Debug JSON
// if let str = String(data: data, encoding: .utf8) {
// print("Market JSON: \(str)")
// }
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
// Check if API returned error
if let error = json["error"] as? [String: Any], let errorText = error["error"] as? String {
await updateItemError(itemId: itemId, error: errorText)
return
}
var allListings: [(price: Int, amount: Int)] = []
// Check itemmarket v2 structure
if let itemmarket = json["itemmarket"] as? [String: Any],
let listings = itemmarket["listings"] as? [[String: Any]] {
let mapped = listings.compactMap { dict -> (Int, Int)? in
guard let p = dict["price"] as? Int else { return nil }
return (p, dict["amount"] as? Int ?? 1)
}
allListings.append(contentsOf: mapped)
}
// Fallback for v1
else if let itemmarketArr = json["itemmarket"] as? [[String: Any]] {
let mapped = itemmarketArr.compactMap { dict -> (Int, Int)? in
guard let p = dict["cost"] as? Int else { return nil }
return (p, dict["quantity"] as? Int ?? 1)
}
allListings.append(contentsOf: mapped)
}
// Check bazaar
if let bazaarArr = json["bazaar"] as? [[String: Any]] {
let mapped = bazaarArr.compactMap { dict -> (Int, Int)? in
guard let p = dict["cost"] as? Int else { return nil }
return (p, dict["quantity"] as? Int ?? 1)
}
allListings.append(contentsOf: mapped)
}
let sortedListings = allListings.sorted { $0.price < $1.price }
// print("Found \(sortedListings.count) listings for item \(itemId). Lowest: \(sortedListings.first?.price ?? 0)")
await MainActor.run {
if let index = watchlistItems.firstIndex(where: { $0.id == itemId }) {
if let best = sortedListings.first {
watchlistItems[index].lowestPrice = best.price
watchlistItems[index].lowestPriceQuantity = best.amount
// Check for next distinct price or just next listing? usually user wants to know diff to next cheapest offer even if it's same price?
// Actually "second lowest price" usually implies the price of the *next available item*.
// But usually users want to know price steps.
// Let's stick to simple logic: price of the 2nd listing in sorted list.
watchlistItems[index].secondLowestPrice = sortedListings.count > 1 ? sortedListings[1].price : 0
watchlistItems[index].lastUpdated = Date()
watchlistItems[index].error = nil
} else {
watchlistItems[index].error = "No listings"
}
saveWatchlist()
}
}
}
} catch {
// print("Price fetch error: \(error)")
await updateItemError(itemId: itemId, error: "Network Error")
}
}
@MainActor
private func updateItemError(itemId: Int, error: String) {
if let index = watchlistItems.firstIndex(where: { $0.id == itemId }) {
watchlistItems[index].error = error
saveWatchlist()
}
}
// MARK: - Polling
func startPolling() {
// Stop existing timer
timerCancellable?.cancel()
// Initial fetch
fetchData()
// Set up polling with configurable interval
timerCancellable = Timer.publish(every: Double(refreshInterval), on: .main, in: .common)
.autoconnect()
.sink { [weak self] _ in
@ -84,6 +226,7 @@ class AppState: ObservableObject {
fetchData()
}
// MARK: - Fetch Data
func fetchData() {
guard !apiKey.isEmpty else {
errorMsg = "API Key required"
@ -108,42 +251,152 @@ class AppState: ObservableObject {
switch httpResponse.statusCode {
case 200:
let decoded = try JSONDecoder().decode(TornResponse.self, from: data)
// Parse on background thread
try await parseDataInBackground(data: data)
if let error = decoded.error {
self.errorMsg = "API Error: \(error.error)"
self.data = nil
} else {
// Check for notifications before updating
checkNotifications(newData: decoded)
// Fetch faction data separately
await fetchFactionData()
self.data = decoded
self.lastUpdated = Date()
self.errorMsg = nil
// Store for comparison
self.previousBars = decoded.bars
self.previousCooldowns = decoded.cooldowns
self.previousTravel = decoded.travel
self.previousChain = decoded.chain
self.previousStatus = decoded.status
}
case 403, 404:
self.errorMsg = "Invalid API Key"
self.data = nil
await MainActor.run {
self.errorMsg = "Invalid API Key"
self.data = nil
self.isLoading = false
}
default:
self.errorMsg = "HTTP Error: \(httpResponse.statusCode)"
await MainActor.run {
self.errorMsg = "HTTP Error: \(httpResponse.statusCode)"
self.isLoading = false
}
}
} catch {
self.errorMsg = error.localizedDescription
await MainActor.run {
self.errorMsg = error.localizedDescription
self.isLoading = false
}
}
self.isLoading = false
}
}
// Move parsing logic here and mark as non-isolated or detached
private func parseDataInBackground(data: Data) async throws {
// Run CPU-heavy parsing detached from MainActor
let result = await Task.detached(priority: .userInitiated) { () -> (TornResponse?, MoneyData?, BattleStats?, [AttackResult]?, [PropertyInfo]?) in
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return (nil, nil, nil, nil, nil)
}
// Attempt to decode TornResponse first
let decodedTornResponse = try? JSONDecoder().decode(TornResponse.self, from: data)
// --- EXTENDED DATA ---
// Money
let cash = json["money_onhand"] as? Int ?? 0
var vault = 0
if let v = json["vault_amount"] as? Int { vault = v }
else if let v = json["property_vault"] as? Int { vault = v }
else if let moneyDict = json["money"] as? [String: Any] { vault = moneyDict["vault"] as? Int ?? 0 }
let points = json["points"] as? Int ?? 0
let tokens = json["donator"] as? Int ?? 0
let cayman = json["cayman_bank"] as? Int ?? 0
let moneyData = MoneyData(cash: cash, vault: vault, points: points, tokens: tokens, cayman: cayman)
// Battle Stats
let strength = json["strength"] as? Int ?? 0
let defense = json["defense"] as? Int ?? 0
let speed = json["speed"] as? Int ?? 0
let dexterity = json["dexterity"] as? Int ?? 0
let battleStats = BattleStats(strength: strength, defense: defense, speed: speed, dexterity: dexterity)
// Attacks
var attacksList: [AttackResult]?
if let attacks = json["attacks"] as? [String: [String: Any]] {
attacksList = attacks.values.compactMap { attackDict -> AttackResult? in
guard let code = attackDict["code"] as? String else { return nil }
return AttackResult(
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,
result: attackDict["result"] as? String,
respect: attackDict["respect"] as? Double
)
}.sorted(by: { ($0.timestampEnded ?? 0) > ($1.timestampEnded ?? 0) })
}
// Properties
var propertiesList: [PropertyInfo]?
if let properties = json["properties"] as? [String: [String: Any]] {
propertiesList = properties.values.compactMap { propDict -> PropertyInfo? in
return PropertyInfo(
id: propDict["property_id"] as? Int ?? 0,
propertyType: propDict["property"] as? String ?? "",
vault: propDict["money"] as? Int ?? 0,
upkeep: propDict["upkeep"] as? Int ?? 0,
rented: propDict["rented"] as? Bool ?? false,
daysUntilUpkeep: propDict["days_left"] as? Int ?? 0
)
}
}
return (decodedTornResponse, moneyData, battleStats, attacksList, propertiesList)
}.value
await MainActor.run {
if let decoded = result.0 {
self.checkNotifications(newData: decoded)
self.data = decoded
self.previousBars = decoded.bars
self.previousCooldowns = decoded.cooldowns
self.previousTravel = decoded.travel
self.previousChain = decoded.chain
self.previousStatus = decoded.status
}
if let m = result.1 { self.moneyData = m }
if let b = result.2 { self.battleStats = b }
if let a = result.3 { self.recentAttacks = a }
if let p = result.4 { self.propertiesData = p }
self.lastUpdated = Date()
self.isLoading = false
self.errorMsg = nil
}
}
// MARK: - Fetch Faction Data
private func fetchFactionData() async {
guard let url = TornAPI.factionURL(for: apiKey) else { return }
do {
let (data, _) = try await URLSession.shared.data(from: url)
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
let name = json["name"] as? String ?? ""
let factionId = json["ID"] as? Int ?? 0
let respect = json["respect"] as? Int ?? 0
var chain = FactionChain()
if let chainDict = json["chain"] as? [String: Any] {
chain = FactionChain(
current: chainDict["current"] as? Int ?? 0,
max: chainDict["max"] as? Int ?? 0,
timeout: chainDict["timeout"] as? Int ?? 0,
cooldown: chainDict["cooldown"] as? Int ?? 0
)
}
self.factionData = FactionData(name: name, factionId: factionId, respect: respect, chain: chain)
}
} catch {
// Faction data is optional, ignore errors
}
}
// MARK: - Notifications
private func checkNotifications(newData: TornResponse) {
// Bar notifications with custom rules
if let prev = previousBars, let current = newData.bars {
checkBarNotification(prevBar: prev.energy, currentBar: current.energy, barType: .energy)
checkBarNotification(prevBar: prev.nerve, currentBar: current.nerve, barType: .nerve)
@ -151,7 +404,6 @@ class AppState: ObservableObject {
checkBarNotification(prevBar: prev.life, currentBar: current.life, barType: .life)
}
// Cooldown notifications
if let prevCD = previousCooldowns, let currentCD = newData.cooldowns {
if prevCD.drug > 0 && currentCD.drug == 0 {
NotificationManager.shared.send(title: "Drug Ready! 💊", body: "Drug cooldown has ended")
@ -164,21 +416,18 @@ class AppState: ObservableObject {
}
}
// Travel notifications
if let prevTravel = previousTravel, let currentTravel = newData.travel {
if prevTravel.isTraveling && !currentTravel.isTraveling {
NotificationManager.shared.send(title: "Landed! ✈️", body: "You have arrived in \(currentTravel.destination)")
NotificationManager.shared.send(title: "Landed! ✈️", body: "You have arrived in \(currentTravel.destination ?? "destination")")
}
}
// Chain timeout warning
if let chain = newData.chain, chain.isActive {
if chain.timeoutRemaining < 60 && chain.timeoutRemaining > 0 {
NotificationManager.shared.send(title: "Chain Expiring! ⚠️", body: "Chain timeout in \(chain.timeoutRemaining) seconds!")
}
}
// Hospital/Jail release
if let prevStatus = previousStatus, let currentStatus = newData.status {
if !prevStatus.isOkay && currentStatus.isOkay {
NotificationManager.shared.send(title: "Released! 🎉", body: "You are now free")
@ -193,7 +442,6 @@ class AppState: ObservableObject {
for rule in notificationRules where rule.enabled && rule.barType == barType {
let threshold = Double(rule.threshold)
// Check if we crossed the threshold upwards
if prevPct < threshold && currentPct >= threshold {
let title: String
switch barType {
@ -204,7 +452,6 @@ class AppState: ObservableObject {
}
NotificationManager.shared.send(title: title, body: "\(barType.rawValue) is now at \(currentBar.current)/\(currentBar.maximum)")
// Play sound
if let sound = NotificationSound(rawValue: rule.soundName) {
SoundManager.shared.play(sound)
}

View file

@ -0,0 +1,140 @@
import SwiftUI
struct AttacksView: View {
@EnvironmentObject var appState: AppState
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
// Battle Stats
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "figure.martial.arts")
.foregroundColor(.red)
Text("Battle Stats")
.font(.caption.bold())
}
if let stats = appState.battleStats {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
StatItem(label: "Strength", value: formatStat(stats.strength), color: .red)
StatItem(label: "Defense", value: formatStat(stats.defense), color: .blue)
StatItem(label: "Speed", value: formatStat(stats.speed), color: .green)
StatItem(label: "Dexterity", value: formatStat(stats.dexterity), color: .orange)
}
Divider()
HStack {
Text("Total:")
.font(.caption)
.foregroundColor(.secondary)
Text(formatStat(stats.total))
.font(.caption.bold())
}
} else {
Text("Loading stats...")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding()
.background(Color.red.opacity(0.05))
.cornerRadius(8)
// Recent Attacks
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "bolt.shield.fill")
.foregroundColor(.orange)
Text("Recent Attacks")
.font(.caption.bold())
}
if let attacks = appState.recentAttacks, !attacks.isEmpty {
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)
}
}
} else {
Text("No recent attacks")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding()
.background(Color.orange.opacity(0.05))
.cornerRadius(8)
// Actions
HStack(spacing: 8) {
ActionButton(title: "Attack", icon: "bolt.fill", color: .red) {
openURL("https://www.torn.com/loader.php?sid=attack&user2ID=")
}
ActionButton(title: "Hospital", icon: "cross.case.fill", color: .pink) {
openURL("https://www.torn.com/hospitalview.php")
}
ActionButton(title: "Bounties", icon: "target", color: .purple) {
openURL("https://www.torn.com/bounties.php")
}
}
}
.padding()
}
.fixedSize(horizontal: false, vertical: true)
}
private func formatStat(_ value: Int) -> String {
if value >= 1_000_000_000 {
return String(format: "%.1fB", Double(value) / 1_000_000_000)
} else if value >= 1_000_000 {
return String(format: "%.1fM", Double(value) / 1_000_000)
} else if value >= 1_000 {
return String(format: "%.1fK", Double(value) / 1_000)
}
return "\(value)"
}
private func openURL(_ urlString: String) {
if let url = URL(string: urlString) {
NSWorkspace.shared.open(url)
}
}
}
// MARK: - Stat Item
struct StatItem: View {
let label: String
let value: String
let color: Color
var body: some View {
VStack(spacing: 2) {
Text(value)
.font(.caption.bold().monospacedDigit())
.foregroundColor(color)
Text(label)
.font(.caption2)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
.background(color.opacity(0.1))
.cornerRadius(4)
}
}

View file

@ -1,57 +1,164 @@
import SwiftUI
enum AppTab: String, CaseIterable {
case status = "Status"
case money = "Money"
case attacks = "Attacks"
case faction = "Faction"
case watchlist = "Watchlist"
var icon: String {
switch self {
case .status: return "chart.bar.fill"
case .money: return "dollarsign.circle.fill"
case .attacks: return "bolt.shield.fill"
case .faction: return "person.3.fill"
case .watchlist: return "chart.line.uptrend.xyaxis"
}
}
}
struct ContentView: View {
@EnvironmentObject var appState: AppState
@State private var showSettings = false
@State private var currentTab: AppTab = .status
var body: some View {
VStack(spacing: 0) {
if appState.apiKey.isEmpty || showSettings {
SettingsView()
.environmentObject(appState)
} else {
// Last updated
if let lastUpdated = appState.lastUpdated {
HStack {
Text("Updated: \(lastUpdated, formatter: timeFormatter)")
.font(.caption2)
.foregroundColor(.secondary)
Spacer()
}
.padding(.horizontal)
.padding(.top, 8)
ZStack {
VStack(spacing: 0) {
if appState.apiKey.isEmpty || showSettings {
SettingsView()
.environmentObject(appState)
} else {
// Header with last updated
headerView
// Tab bar
tabBar
Divider()
// Content based on selected tab
tabContent
}
StatusView()
.environmentObject(appState)
Divider()
.padding(.vertical, 4)
// Footer buttons
footerView
}
.disabled(appState.isLoading && appState.lastUpdated == nil) // Disable interaction if initial loading
Divider()
.padding(.vertical, 4)
// Loading Overlay
if appState.isLoading && appState.lastUpdated == nil {
Color.black.opacity(0.4)
.background(.ultraThinMaterial)
// Footer buttons
HStack {
if !appState.apiKey.isEmpty {
Button(showSettings ? "Back" : "Settings") {
showSettings.toggle()
}
.buttonStyle(.plain)
.foregroundColor(.secondary)
VStack(spacing: 12) {
ProgressView()
.controlSize(.large)
Text("Loading Torn Data...")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
.frame(width: 320)
.onAppear {
appState.startPolling()
}
.task {
await NotificationManager.shared.requestPermission()
}
}
Spacer()
// MARK: - Header
private var headerView: some View {
HStack {
if let lastUpdated = appState.lastUpdated {
Text("Updated: \(lastUpdated, formatter: timeFormatter)")
.font(.caption2)
.foregroundColor(.secondary)
}
Spacer()
}
.padding(.horizontal)
.padding(.top, 8)
}
Button("Quit") {
NSApplication.shared.terminate(nil)
// MARK: - Tab Bar
private var tabBar: some View {
HStack(spacing: 4) {
ForEach(AppTab.allCases, id: \.self) { tab in
Button {
currentTab = tab
} label: {
VStack(spacing: 2) {
Image(systemName: tab.icon)
.font(.system(size: 14))
Text(tab.rawValue)
.font(.system(size: 8))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(currentTab == tab ? Color.accentColor.opacity(0.2) : Color.clear)
.cornerRadius(6)
.contentShape(Rectangle()) // Make entire area clickable
}
.buttonStyle(.plain)
.foregroundColor(currentTab == tab ? .accentColor : .secondary)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
}
// MARK: - Tab Content
@ViewBuilder
private var tabContent: some View {
switch currentTab {
case .status:
StatusView()
.environmentObject(appState)
case .money:
MoneyView()
.environmentObject(appState)
case .attacks:
AttacksView()
.environmentObject(appState)
case .faction:
FactionView()
.environmentObject(appState)
case .watchlist:
WatchlistView()
.environmentObject(appState)
}
}
// MARK: - Footer
private var footerView: some View {
HStack {
if !appState.apiKey.isEmpty {
Button(showSettings ? "Back" : "Settings") {
showSettings.toggle()
}
.buttonStyle(.plain)
.foregroundColor(.secondary)
}
.font(.caption)
.padding(.horizontal)
.padding(.bottom, 8)
Spacer()
Button("Quit") {
NSApplication.shared.terminate(nil)
}
.buttonStyle(.plain)
.foregroundColor(.secondary)
}
.frame(width: 300)
.font(.caption)
.padding(.horizontal)
.padding(.bottom, 8)
}
private var timeFormatter: DateFormatter {

View file

@ -0,0 +1,162 @@
import SwiftUI
struct FactionView: View {
@EnvironmentObject var appState: AppState
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
// Faction Info
VStack(alignment: .leading, spacing: 8) {
if let faction = appState.factionData {
HStack {
Image(systemName: "person.3.fill")
.foregroundColor(.blue)
Text(faction.name)
.font(.caption.bold())
Spacer()
Text("[\(faction.factionId)]")
.font(.caption2)
.foregroundColor(.secondary)
}
// Chain Status
if faction.chain.current > 0 {
HStack {
Image(systemName: "link")
.foregroundColor(chainColor(faction.chain))
Text("Chain: \(faction.chain.current)/\(faction.chain.max)")
.font(.caption.bold())
Spacer()
Text(formatTime(faction.chain.timeout))
.font(.caption.monospacedDigit())
.foregroundColor(chainColor(faction.chain))
}
.padding(8)
.background(chainColor(faction.chain).opacity(0.1))
.cornerRadius(6)
}
// Respect
HStack {
Text("Respect:")
.font(.caption)
.foregroundColor(.secondary)
Text(formatNumber(faction.respect))
.font(.caption.bold())
}
} else {
HStack {
Image(systemName: "person.3.fill")
.foregroundColor(.blue)
Text("Faction")
.font(.caption.bold())
}
Text("Loading faction data...")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding()
.background(Color.blue.opacity(0.05))
.cornerRadius(8)
// Armory Quick Actions
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "shield.fill")
.foregroundColor(.purple)
Text("Armory Quick Use")
.font(.caption.bold())
}
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
ArmoryButton(title: "Xanax", icon: "pills.fill", color: .blue) {
openURL("https://www.torn.com/factions.php?step=your#/tab=armoury&start=0&sub=donate")
}
ArmoryButton(title: "Refill", icon: "drop.fill", color: .cyan) {
openURL("https://www.torn.com/factions.php?step=your#/tab=armoury")
}
ArmoryButton(title: "SED", icon: "syringe.fill", color: .green) {
openURL("https://www.torn.com/factions.php?step=your#/tab=armoury")
}
}
}
.padding()
.background(Color.purple.opacity(0.05))
.cornerRadius(8)
// Actions
HStack(spacing: 8) {
ActionButton(title: "Faction", icon: "person.3.fill", color: .blue) {
openURL("https://www.torn.com/factions.php?step=your")
}
ActionButton(title: "Wars", icon: "flame.fill", color: .red) {
openURL("https://www.torn.com/factions.php?step=your#/tab=wars")
}
ActionButton(title: "OC", icon: "briefcase.fill", color: .orange) {
openURL("https://www.torn.com/factions.php?step=your#/tab=crimes")
}
}
}
.padding()
}
.fixedSize(horizontal: false, vertical: true)
}
private func chainColor(_ chain: FactionChain) -> Color {
if chain.timeout < 60 {
return .red
} else if chain.timeout < 180 {
return .orange
}
return .green
}
private func formatTime(_ seconds: Int) -> String {
let mins = seconds / 60
let secs = seconds % 60
return String(format: "%d:%02d", mins, secs)
}
private func formatNumber(_ value: Int) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
return formatter.string(from: NSNumber(value: value)) ?? "\(value)"
}
private func openURL(_ urlString: String) {
if let url = URL(string: urlString) {
NSWorkspace.shared.open(url)
}
}
}
// MARK: - Armory Button
struct ArmoryButton: View {
let title: String
let icon: String
let color: Color
let action: () -> Void
var body: some View {
Button(action: action) {
VStack(spacing: 2) {
Image(systemName: icon)
.font(.system(size: 14))
Text(title)
.font(.caption2)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(color.opacity(0.15))
.foregroundColor(color)
.cornerRadius(6)
}
.buttonStyle(.plain)
}
}

View file

@ -0,0 +1,136 @@
import SwiftUI
struct MoneyView: View {
@EnvironmentObject var appState: AppState
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
// Balance Section
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "dollarsign.circle.fill")
.foregroundColor(.green)
Text("Balance")
.font(.caption.bold())
}
if let money = appState.moneyData {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Cash")
.font(.caption2)
.foregroundColor(.secondary)
Text(formatMoney(money.cash))
.font(.headline.monospacedDigit())
.foregroundColor(.green)
}
Spacer()
VStack(alignment: .trailing, spacing: 4) {
Text("Vault")
.font(.caption2)
.foregroundColor(.secondary)
Text(formatMoney(money.vault))
.font(.headline.monospacedDigit())
}
}
Divider()
HStack(spacing: 16) {
VStack {
Text("\(money.points)")
.font(.caption.bold().monospacedDigit())
Text("Points")
.font(.caption2)
.foregroundColor(.secondary)
}
VStack {
Text("\(money.tokens)")
.font(.caption.bold().monospacedDigit())
Text("Tokens")
.font(.caption2)
.foregroundColor(.secondary)
}
VStack {
Text("\(money.cayman)")
.font(.caption.bold().monospacedDigit())
Text("Cayman")
.font(.caption2)
.foregroundColor(.secondary)
}
}
.frame(maxWidth: .infinity)
} else {
Text("Loading...")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding()
.background(Color.green.opacity(0.05))
.cornerRadius(8)
// Actions
HStack(spacing: 8) {
ActionButton(title: "Send Money", icon: "paperplane.fill", color: .blue) {
openURL("https://www.torn.com/sendcash.php")
}
ActionButton(title: "Bazaar", icon: "cart.fill", color: .orange) {
openURL("https://www.torn.com/bazaar.php")
}
ActionButton(title: "Bank", icon: "building.columns.fill", color: .purple) {
openURL("https://www.torn.com/bank.php")
}
}
}
.padding()
}
.fixedSize(horizontal: false, vertical: true)
}
private func formatMoney(_ amount: Int) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencySymbol = "$"
formatter.maximumFractionDigits = 0
return formatter.string(from: NSNumber(value: amount)) ?? "$\(amount)"
}
private func openURL(_ urlString: String) {
if let url = URL(string: urlString) {
NSWorkspace.shared.open(url)
}
}
}
// MARK: - Action Button Component
struct ActionButton: View {
let title: String
let icon: String
let color: Color
let action: () -> Void
var body: some View {
Button(action: action) {
VStack(spacing: 4) {
Image(systemName: icon)
.font(.system(size: 16))
Text(title)
.font(.caption2)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(color.opacity(0.1))
.foregroundColor(color)
.cornerRadius(8)
}
.buttonStyle(.plain)
}
}

View file

@ -0,0 +1,126 @@
import SwiftUI
struct PropertiesView: View {
@EnvironmentObject var appState: AppState
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
// Property Info
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "house.fill")
.foregroundColor(.brown)
Text("Properties")
.font(.caption.bold())
}
if let properties = appState.propertiesData, !properties.isEmpty {
ForEach(Array(properties.enumerated()), id: \.offset) { index, property in
PropertyCard(property: property)
}
} else {
VStack(spacing: 8) {
Image(systemName: "house.slash")
.font(.title2)
.foregroundColor(.secondary)
Text("No properties found")
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 20)
}
}
// Actions
HStack(spacing: 8) {
ActionButton(title: "Properties", icon: "house.fill", color: .brown) {
openURL("https://www.torn.com/properties.php")
}
ActionButton(title: "Estate Agents", icon: "building.2.fill", color: .blue) {
openURL("https://www.torn.com/estateagents.php")
}
}
}
.padding()
}
.fixedSize(horizontal: false, vertical: true)
}
private func openURL(_ urlString: String) {
if let url = URL(string: urlString) {
NSWorkspace.shared.open(url)
}
}
}
// MARK: - Property Card
struct PropertyCard: View {
let property: PropertyInfo
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(property.propertyType)
.font(.caption.bold())
Spacer()
if property.rented {
Text("Rented")
.font(.caption2)
.foregroundColor(.orange)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.orange.opacity(0.2))
.cornerRadius(4)
}
}
Divider()
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Vault")
.font(.caption2)
.foregroundColor(.secondary)
Text(formatMoney(property.vault))
.font(.caption.bold().monospacedDigit())
.foregroundColor(.green)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("Upkeep")
.font(.caption2)
.foregroundColor(.secondary)
Text(formatMoney(property.upkeep))
.font(.caption.bold().monospacedDigit())
.foregroundColor(.red)
}
}
if property.daysUntilUpkeep > 0 {
HStack {
Image(systemName: "clock")
.font(.caption2)
Text("Due in \(property.daysUntilUpkeep) days")
.font(.caption2)
}
.foregroundColor(property.daysUntilUpkeep <= 3 ? .orange : .secondary)
}
}
.padding()
.background(Color.brown.opacity(0.05))
.cornerRadius(8)
}
private func formatMoney(_ amount: Int) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencySymbol = "$"
formatter.maximumFractionDigits = 0
return formatter.string(from: NSNumber(value: amount)) ?? "$\(amount)"
}
}

View file

@ -51,19 +51,18 @@ struct SettingsView: View {
.foregroundColor(.secondary)
.frame(width: 20)
Picker("Refresh", selection: Binding(
get: { appState.refreshInterval },
set: { newValue in
appState.refreshInterval = newValue
appState.startPolling()
}
)) {
Picker("Refresh", selection: $appState.refreshInterval) {
Text("15s").tag(15)
Text("30s").tag(30)
Text("60s").tag(60)
Text("2m").tag(120)
}
.pickerStyle(.segmented)
.onChange(of: appState.refreshInterval) { _ in
Task { @MainActor in
appState.startPolling()
}
}
}
// Launch at Login

View file

@ -0,0 +1,215 @@
import SwiftUI
struct WatchlistView: View {
@EnvironmentObject var appState: AppState
@State private var showAddItem = false
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
// Watchlist Header
HStack {
Image(systemName: "chart.line.uptrend.xyaxis")
.foregroundColor(.green)
Text("Price Watch")
.font(.caption.bold())
Spacer()
// Refresh button
Button {
appState.refreshWatchlistPrices()
} label: {
Image(systemName: "arrow.clockwise")
.foregroundColor(.blue)
}
.buttonStyle(.plain)
Button {
withAnimation {
showAddItem.toggle()
}
} label: {
Image(systemName: showAddItem ? "minus.circle.fill" : "plus.circle.fill")
.foregroundColor(showAddItem ? .red : .green)
}
.buttonStyle(.plain)
}
// Add Item Section (inline)
if showAddItem {
VStack(alignment: .leading, spacing: 4) {
Text("Add item:")
.font(.caption2)
.foregroundColor(.secondary)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 4) {
ForEach(popularItems, id: \.1) { item in
Button {
appState.addToWatchlist(itemId: item.1, name: item.0)
withAnimation {
showAddItem = false
}
} label: {
Text(item.0)
.font(.caption2)
.lineLimit(1)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(Color.green.opacity(0.1))
.cornerRadius(4)
}
.buttonStyle(.plain)
}
}
}
.padding(8)
.background(Color.gray.opacity(0.1))
.cornerRadius(6)
}
// Watchlist Items with Prices
if appState.watchlistItems.isEmpty && !showAddItem {
VStack(spacing: 8) {
Image(systemName: "tag")
.font(.title2)
.foregroundColor(.secondary)
Text("No items watched")
.font(.caption)
.foregroundColor(.secondary)
Text("Track Item Market prices")
.font(.caption2)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 20)
} else if !appState.watchlistItems.isEmpty {
ForEach(appState.watchlistItems) { item in
WatchlistPriceRow(item: item) {
openURL("https://www.torn.com/imarket.php#/p=shop&step=shop&type=&searchname=\(item.name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? item.name)")
} onRemove: {
appState.removeFromWatchlist(item.id)
}
}
}
Divider()
// Quick Market Links
HStack(spacing: 8) {
ActionButton(title: "Item Market", icon: "bag.fill", color: .blue) {
openURL("https://www.torn.com/imarket.php")
}
ActionButton(title: "Points", icon: "star.fill", color: .orange) {
openURL("https://www.torn.com/pmarket.php")
}
}
}
.padding()
}
.fixedSize(horizontal: false, vertical: true)
.task {
appState.refreshWatchlistPrices()
}
}
private let popularItems = [
("Xanax", 206),
("FHC", 367),
("Donator Pack", 617),
("Drug Pack", 370),
("Energy Drink", 261),
("First Aid Kit", 68)
]
private func openURL(_ urlString: String) {
if let url = URL(string: urlString) {
NSWorkspace.shared.open(url)
}
}
}
// MARK: - Watchlist Price Row
struct WatchlistPriceRow: View {
let item: WatchlistItem
let onOpen: () -> Void
let onRemove: () -> Void
var body: some View {
HStack {
// Item name & open button
Button(action: onOpen) {
HStack(spacing: 4) {
Image(systemName: "magnifyingglass")
.foregroundColor(.blue)
.font(.caption2)
Text(item.name)
.font(.caption.bold())
}
}
.buttonStyle(.plain)
Spacer()
// Price info
if let error = item.error {
HStack(spacing: 4) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
.font(.caption2)
Text(error)
.font(.caption2)
.foregroundColor(.red)
}
} else if item.isLoading {
ProgressView()
.scaleEffect(0.6)
} else {
VStack(alignment: .trailing, spacing: 1) {
HStack(spacing: 4) {
Text(formatPrice(item.lowestPrice))
.font(.caption.monospacedDigit().bold())
.foregroundColor(.green)
if item.lowestPriceQuantity > 1 {
Text("x\(item.lowestPriceQuantity)")
.font(.caption2)
.foregroundColor(.secondary)
}
}
if item.priceDifference > 0 {
HStack(spacing: 2) {
Image(systemName: "arrow.up")
.font(.system(size: 8))
Text("+\(formatPrice(item.priceDifference))")
.font(.caption2.monospacedDigit())
}
.foregroundColor(.orange)
}
}
}
// Remove button
Button(action: onRemove) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.gray)
.font(.caption)
}
.buttonStyle(.plain)
}
.padding(8)
.background(Color.gray.opacity(0.1))
.cornerRadius(6)
}
private func formatPrice(_ price: Int) -> String {
if price >= 1_000_000 {
return String(format: "$%.1fM", Double(price) / 1_000_000)
} else if price >= 1_000 {
return String(format: "$%.0fK", Double(price) / 1_000)
}
return "$\(price)"
}
}

View file

@ -10,17 +10,43 @@ A native macOS menu bar app for monitoring your **Torn** game status.
## Features
- 📊 **Live Status Bars** - Energy, Nerve, Happy, Life with color-coded progress
- ⏱️ **Cooldown Timers** - Drug, Medical, Booster countdowns with ready state
- ✈️ **Travel Monitoring** - Destination tracking with arrival countdown and abroad state
- 🔗 **Chain Timer** - Active chain counter with timeout warning + cooldown state
- 🏥 **Hospital/Jail Status** - Countdown to release
- 📨 **Unread Messages** - Inbox badge with one-click open
- 🔔 **Events Feed** - Recent activity at a glance
- 🔔 **Notifications** - Bars thresholds, cooldown ready, landing, chain expiring, and release
- ⚡ **Quick Links** - Grid of customizable Torn shortcuts (8 defaults)
- 🕒 **Refresh Control** - 15s/30s/60s/2m polling + manual refresh + last updated
- 🚀 **Launch at Login** - Start automatically with macOS
### 📊 Status Tab
- Live Energy, Nerve, Happy, Life bars with color-coded progress
- Cooldown timers (Drug, Medical, Booster)
- Travel monitoring with arrival countdown
- Chain timer with timeout warning
- Hospital/Jail status badges
- Unread messages badge
- Events feed
- 8 customizable quick links
### 💰 Money Tab
- Cash, Vault, Points, Tokens display
- Quick actions: Send Money, Bazaar, Bank
### ⚔️ Attacks Tab
- Battle stats (Strength, Defense, Speed, Dexterity)
- Recent attacks with W/L results
- Quick actions: Attack, Hospital, Bounties
### 🏢 Faction Tab
- Faction info and chain status
- War status display
- Armory quick-use buttons
### 📈 Watchlist Tab
- Track item prices
- Price change indicators
- Add/remove items from watchlist
### 🏠 Properties Tab
- Property info and vault contents
- Upkeep status and countdown
### ⚙️ General
- 🔔 Smart notifications for bars, cooldowns, landing, chain
- 🕒 Configurable refresh intervals (15s/30s/60s/2m)
- 🚀 Launch at Login
## Installation