mirror of
https://github.com/pawelorzech/MacTorn.git
synced 2026-01-29 19:54:27 +00:00
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:
parent
7f947b635c
commit
e75131aa67
11 changed files with 1540 additions and 98 deletions
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
140
MacTorn/MacTorn/Views/AttacksView.swift
Normal file
140
MacTorn/MacTorn/Views/AttacksView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
162
MacTorn/MacTorn/Views/FactionView.swift
Normal file
162
MacTorn/MacTorn/Views/FactionView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
136
MacTorn/MacTorn/Views/MoneyView.swift
Normal file
136
MacTorn/MacTorn/Views/MoneyView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
126
MacTorn/MacTorn/Views/PropertiesView.swift
Normal file
126
MacTorn/MacTorn/Views/PropertiesView.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
215
MacTorn/MacTorn/Views/WatchlistView.swift
Normal file
215
MacTorn/MacTorn/Views/WatchlistView.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
48
README.md
48
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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue