diff --git a/MacTorn/MacTorn.xcodeproj/project.pbxproj b/MacTorn/MacTorn.xcodeproj/project.pbxproj index 58d49c0..83b0b3e 100644 --- a/MacTorn/MacTorn.xcodeproj/project.pbxproj +++ b/MacTorn/MacTorn.xcodeproj/project.pbxproj @@ -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 = ""; }; AAA10015 /* EventsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventsView.swift; sourceTree = ""; }; AAA10016 /* SoundManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundManager.swift; sourceTree = ""; }; + AAA10017 /* MoneyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoneyView.swift; sourceTree = ""; }; + AAA10018 /* AttacksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttacksView.swift; sourceTree = ""; }; + AAA10019 /* FactionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactionView.swift; sourceTree = ""; }; + AAA10020 /* WatchlistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchlistView.swift; sourceTree = ""; }; + AAA10021 /* PropertiesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropertiesView.swift; sourceTree = ""; }; 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; }; diff --git a/MacTorn/MacTorn/Models/TornModels.swift b/MacTorn/MacTorn/Models/TornModels.swift index 51868f2..f795376 100644 --- a/MacTorn/MacTorn/Models/TornModels.swift +++ b/MacTorn/MacTorn/Models/TornModels.swift @@ -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 diff --git a/MacTorn/MacTorn/ViewModels/AppState.swift b/MacTorn/MacTorn/ViewModels/AppState.swift index 6314fd7..d6a5614 100644 --- a/MacTorn/MacTorn/ViewModels/AppState.swift +++ b/MacTorn/MacTorn/ViewModels/AppState.swift @@ -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) + + // Fetch faction data separately + await fetchFactionData() - if let error = decoded.error { - self.errorMsg = "API Error: \(error.error)" - self.data = nil - } else { - // Check for notifications before updating - checkNotifications(newData: decoded) - - 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) } diff --git a/MacTorn/MacTorn/Views/AttacksView.swift b/MacTorn/MacTorn/Views/AttacksView.swift new file mode 100644 index 0000000..ef02e75 --- /dev/null +++ b/MacTorn/MacTorn/Views/AttacksView.swift @@ -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) + } +} diff --git a/MacTorn/MacTorn/Views/ContentView.swift b/MacTorn/MacTorn/Views/ContentView.swift index 003a2cb..0e7bbed 100644 --- a/MacTorn/MacTorn/Views/ContentView.swift +++ b/MacTorn/MacTorn/Views/ContentView.swift @@ -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) - - // Footer buttons - HStack { - if !appState.apiKey.isEmpty { - Button(showSettings ? "Back" : "Settings") { - showSettings.toggle() - } - .buttonStyle(.plain) - .foregroundColor(.secondary) + // Loading Overlay + if appState.isLoading && appState.lastUpdated == nil { + Color.black.opacity(0.4) + .background(.ultraThinMaterial) + + VStack(spacing: 12) { + ProgressView() + .controlSize(.large) + Text("Loading Torn Data...") + .font(.caption) + .foregroundColor(.secondary) } - - Spacer() - - Button("Quit") { - NSApplication.shared.terminate(nil) + } + } + .frame(width: 320) + .onAppear { + appState.startPolling() + } + .task { + await NotificationManager.shared.requestPermission() + } + } + + // 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) + } + + // 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 { diff --git a/MacTorn/MacTorn/Views/FactionView.swift b/MacTorn/MacTorn/Views/FactionView.swift new file mode 100644 index 0000000..1fa44e7 --- /dev/null +++ b/MacTorn/MacTorn/Views/FactionView.swift @@ -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) + } +} diff --git a/MacTorn/MacTorn/Views/MoneyView.swift b/MacTorn/MacTorn/Views/MoneyView.swift new file mode 100644 index 0000000..a089021 --- /dev/null +++ b/MacTorn/MacTorn/Views/MoneyView.swift @@ -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) + } +} diff --git a/MacTorn/MacTorn/Views/PropertiesView.swift b/MacTorn/MacTorn/Views/PropertiesView.swift new file mode 100644 index 0000000..c38143f --- /dev/null +++ b/MacTorn/MacTorn/Views/PropertiesView.swift @@ -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)" + } +} diff --git a/MacTorn/MacTorn/Views/SettingsView.swift b/MacTorn/MacTorn/Views/SettingsView.swift index ba3ce6e..edf0a48 100644 --- a/MacTorn/MacTorn/Views/SettingsView.swift +++ b/MacTorn/MacTorn/Views/SettingsView.swift @@ -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 diff --git a/MacTorn/MacTorn/Views/WatchlistView.swift b/MacTorn/MacTorn/Views/WatchlistView.swift new file mode 100644 index 0000000..c7e607e --- /dev/null +++ b/MacTorn/MacTorn/Views/WatchlistView.swift @@ -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)" + } +} diff --git a/README.md b/README.md index 5a5713e..8858f83 100644 --- a/README.md +++ b/README.md @@ -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