diff --git a/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/Contents.json b/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/Contents.json index 4662e62..82f6f39 100644 --- a/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,51 +1,61 @@ { "images": [ { + "filename": "icon_16x16.png", "idiom": "mac", "scale": "1x", "size": "16x16" }, { + "filename": "icon_16x16@2x.png", "idiom": "mac", "scale": "2x", "size": "16x16" }, { + "filename": "icon_32x32.png", "idiom": "mac", "scale": "1x", "size": "32x32" }, { + "filename": "icon_32x32@2x.png", "idiom": "mac", "scale": "2x", "size": "32x32" }, { + "filename": "icon_128x128.png", "idiom": "mac", "scale": "1x", "size": "128x128" }, { + "filename": "icon_128x128@2x.png", "idiom": "mac", "scale": "2x", "size": "128x128" }, { + "filename": "icon_256x256.png", "idiom": "mac", "scale": "1x", "size": "256x256" }, { + "filename": "icon_256x256@2x.png", "idiom": "mac", "scale": "2x", "size": "256x256" }, { + "filename": "icon_512x512.png", "idiom": "mac", "scale": "1x", "size": "512x512" }, { + "filename": "icon_512x512@2x.png", "idiom": "mac", "scale": "2x", "size": "512x512" diff --git a/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/icon_128x128.png new file mode 100644 index 0000000..3c0f4eb Binary files /dev/null and b/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/icon_128x128.png differ diff --git a/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png new file mode 100644 index 0000000..455d2cc Binary files /dev/null and b/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png differ diff --git a/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/icon_16x16.png new file mode 100644 index 0000000..5d73614 Binary files /dev/null and b/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/icon_16x16.png differ diff --git a/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png new file mode 100644 index 0000000..38c3f0f Binary files /dev/null and b/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png differ diff --git a/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/icon_256x256.png new file mode 100644 index 0000000..455d2cc Binary files /dev/null and b/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/icon_256x256.png differ diff --git a/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png new file mode 100644 index 0000000..5e419fb Binary files /dev/null and b/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png differ diff --git a/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/icon_32x32.png new file mode 100644 index 0000000..38c3f0f Binary files /dev/null and b/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/icon_32x32.png differ diff --git a/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png new file mode 100644 index 0000000..3dc7020 Binary files /dev/null and b/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png differ diff --git a/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/icon_512x512.png new file mode 100644 index 0000000..5e419fb Binary files /dev/null and b/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/icon_512x512.png differ diff --git a/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png new file mode 100644 index 0000000..8b0046e Binary files /dev/null and b/MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ diff --git a/MacTorn/MacTorn/Models/TornModels.swift b/MacTorn/MacTorn/Models/TornModels.swift index 5dc1b54..af6fc66 100644 --- a/MacTorn/MacTorn/Models/TornModels.swift +++ b/MacTorn/MacTorn/Models/TornModels.swift @@ -10,13 +10,18 @@ struct TornResponse: Codable { let happy: Bar? let cooldowns: Cooldowns? let travel: Travel? + let status: Status? + let chain: Chain? + let events: [String: TornEvent]? + let messages: [String: TornMessage]? let error: TornError? enum CodingKeys: String, CodingKey { case name case playerId = "player_id" case energy, nerve, life, happy - case cooldowns, travel, error + case cooldowns, travel, status, chain + case events, messages, error } // Convenience computed property @@ -27,9 +32,20 @@ struct TornResponse: Codable { let happy = happy else { return nil } return Bars(energy: energy, nerve: nerve, life: life, happy: happy) } + + // Unread messages count + var unreadMessagesCount: Int { + messages?.values.filter { $0.read == 0 }.count ?? 0 + } + + // Recent events sorted + var recentEvents: [TornEvent] { + guard let events = events else { return [] } + return events.values.sorted { $0.timestamp > $1.timestamp } + } } -// MARK: - Bars (for internal use) +// MARK: - Bars struct Bar: Codable, Equatable { let current: Int let maximum: Int @@ -46,6 +62,11 @@ struct Bar: Codable, Equatable { self.ticktime = ticktime self.fulltime = fulltime } + + var percentage: Double { + guard maximum > 0 else { return 0 } + return Double(current) / Double(maximum) * 100 + } } struct Bars: Equatable { @@ -90,6 +111,77 @@ struct Travel: Codable, Equatable { } } +// MARK: - Status (Hospital/Jail) +struct Status: Codable, Equatable { + let description: String + let details: String? + let state: String + let until: Int + + var isInHospital: Bool { + state == "Hospital" + } + + var isInJail: Bool { + state == "Jail" + } + + var isOkay: Bool { + state == "Okay" + } + + var timeRemaining: Int { + max(0, until - Int(Date().timeIntervalSince1970)) + } +} + +// MARK: - Chain +struct Chain: Codable, Equatable { + let current: Int + let maximum: Int + let timeout: Int + let cooldown: Int + + var isActive: Bool { + current > 0 && timeout > 0 + } + + var isOnCooldown: Bool { + cooldown > 0 + } + + var timeoutRemaining: Int { + max(0, timeout - Int(Date().timeIntervalSince1970)) + } +} + +// MARK: - Events +struct TornEvent: Codable, Identifiable { + let timestamp: Int + let event: String + let seen: Int + + var id: Int { timestamp } + + var date: Date { + Date(timeIntervalSince1970: TimeInterval(timestamp)) + } + + // Strip HTML tags from event text + var cleanEvent: String { + event.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) + } +} + +// MARK: - Messages +struct TornMessage: Codable { + let name: String + let type: String + let title: String + let timestamp: Int + let read: Int +} + // MARK: - Error struct TornError: Codable { let code: Int @@ -99,13 +191,60 @@ struct TornError: Codable { // MARK: - API Configuration enum TornAPI { static let baseURL = "https://api.torn.com/user/" - static let selections = "basic,bars,cooldowns,travel" + static let selections = "basic,bars,cooldowns,travel,profile,events,messages" static func url(for apiKey: String) -> URL? { URL(string: "\(baseURL)?selections=\(selections)&key=\(apiKey)") } } +// MARK: - Notification Settings +struct NotificationRule: Codable, Identifiable, Equatable { + let id: String + var barType: BarType + var threshold: Int // Percentage 0-100 + var enabled: Bool + var soundName: String + + enum BarType: String, Codable, CaseIterable { + case energy = "Energy" + case nerve = "Nerve" + case happy = "Happy" + case life = "Life" + } + + static let defaults: [NotificationRule] = [ + NotificationRule(id: "energy_full", barType: .energy, threshold: 100, enabled: true, soundName: "default"), + NotificationRule(id: "energy_high", barType: .energy, threshold: 80, enabled: false, soundName: "default"), + NotificationRule(id: "nerve_full", barType: .nerve, threshold: 100, enabled: true, soundName: "default"), + NotificationRule(id: "happy_full", barType: .happy, threshold: 100, enabled: false, soundName: "default"), + NotificationRule(id: "life_low", barType: .life, threshold: 20, enabled: false, soundName: "default") + ] +} + +// MARK: - Sound Options +enum NotificationSound: String, CaseIterable { + case `default` = "default" + case ping = "Ping" + case glass = "Glass" + case hero = "Hero" + case pop = "Pop" + case submarine = "Submarine" + case none = "None" + + var displayName: String { + switch self { + case .default: return "Default" + case .ping: return "Ping" + case .glass: return "Glass" + case .hero: return "Hero" + case .pop: return "Pop" + case .submarine: return "Submarine" + case .none: return "None" + } + } +} + // MARK: - Keyboard Shortcuts struct KeyboardShortcut: Identifiable, Codable, Equatable { let id: String @@ -115,61 +254,13 @@ struct KeyboardShortcut: Identifiable, Codable, Equatable { var modifiers: [String] static let defaults: [KeyboardShortcut] = [ - KeyboardShortcut( - id: "home", - name: "Home", - url: "https://www.torn.com/", - keyEquivalent: "h", - modifiers: ["command", "shift"] - ), - KeyboardShortcut( - id: "items", - name: "Items", - url: "https://www.torn.com/item.php", - keyEquivalent: "i", - modifiers: ["command", "shift"] - ), - KeyboardShortcut( - id: "gym", - name: "Gym", - url: "https://www.torn.com/gym.php", - keyEquivalent: "g", - modifiers: ["command", "shift"] - ), - KeyboardShortcut( - id: "crimes", - name: "Crimes", - url: "https://www.torn.com/crimes.php", - keyEquivalent: "c", - modifiers: ["command", "shift"] - ), - KeyboardShortcut( - id: "mission", - name: "Missions", - url: "https://www.torn.com/missions.php", - keyEquivalent: "m", - modifiers: ["command", "shift"] - ), - KeyboardShortcut( - id: "travel", - name: "Travel", - url: "https://www.torn.com/travelagency.php", - keyEquivalent: "t", - modifiers: ["command", "shift"] - ), - KeyboardShortcut( - id: "hospital", - name: "Hospital", - url: "https://www.torn.com/hospitalview.php", - keyEquivalent: "o", - modifiers: ["command", "shift"] - ), - KeyboardShortcut( - id: "faction", - name: "Faction", - url: "https://www.torn.com/factions.php", - keyEquivalent: "f", - modifiers: ["command", "shift"] - ) + KeyboardShortcut(id: "home", name: "Home", url: "https://www.torn.com/", keyEquivalent: "h", modifiers: ["command", "shift"]), + KeyboardShortcut(id: "items", name: "Items", url: "https://www.torn.com/item.php", keyEquivalent: "i", modifiers: ["command", "shift"]), + KeyboardShortcut(id: "gym", name: "Gym", url: "https://www.torn.com/gym.php", keyEquivalent: "g", modifiers: ["command", "shift"]), + KeyboardShortcut(id: "crimes", name: "Crimes", url: "https://www.torn.com/crimes.php", keyEquivalent: "c", modifiers: ["command", "shift"]), + KeyboardShortcut(id: "mission", name: "Missions", url: "https://www.torn.com/missions.php", keyEquivalent: "m", modifiers: ["command", "shift"]), + KeyboardShortcut(id: "travel", name: "Travel", url: "https://www.torn.com/travelagency.php", keyEquivalent: "t", modifiers: ["command", "shift"]), + KeyboardShortcut(id: "hospital", name: "Hospital", url: "https://www.torn.com/hospitalview.php", keyEquivalent: "o", modifiers: ["command", "shift"]), + KeyboardShortcut(id: "faction", name: "Faction", url: "https://www.torn.com/factions.php", keyEquivalent: "f", modifiers: ["command", "shift"]) ] } diff --git a/MacTorn/MacTorn/Utilities/SoundManager.swift b/MacTorn/MacTorn/Utilities/SoundManager.swift new file mode 100644 index 0000000..a3067e1 --- /dev/null +++ b/MacTorn/MacTorn/Utilities/SoundManager.swift @@ -0,0 +1,34 @@ +import SwiftUI +import AppKit + +class SoundManager { + static let shared = SoundManager() + + private init() {} + + func play(_ sound: NotificationSound) { + guard sound != .none else { return } + + if sound == .default { + NSSound.beep() + return + } + + // Try system sounds + if let systemSound = NSSound(named: sound.rawValue) { + systemSound.play() + } else { + // Fallback to beep + NSSound.beep() + } + } + + func playForEvent(_ eventType: String, rules: [NotificationRule]) { + // Find matching rule and play its sound + if let rule = rules.first(where: { $0.enabled && $0.barType.rawValue.lowercased() == eventType.lowercased() }) { + if let sound = NotificationSound(rawValue: rule.soundName) { + play(sound) + } + } + } +} diff --git a/MacTorn/MacTorn/Views/Components/ChainView.swift b/MacTorn/MacTorn/Views/Components/ChainView.swift new file mode 100644 index 0000000..1738a74 --- /dev/null +++ b/MacTorn/MacTorn/Views/Components/ChainView.swift @@ -0,0 +1,50 @@ +import SwiftUI + +struct ChainView: View { + let chain: Chain + + var body: some View { + if chain.isActive { + VStack(alignment: .leading, spacing: 4) { + HStack { + Image(systemName: "link") + .foregroundColor(timeoutColor) + Text("Chain: \(chain.current)/\(chain.maximum)") + .font(.caption.bold()) + + Spacer() + + Text(formatTime(chain.timeoutRemaining)) + .font(.caption.monospacedDigit()) + .foregroundColor(timeoutColor) + } + } + .padding(8) + .background(timeoutColor.opacity(0.1)) + .cornerRadius(8) + } else if chain.isOnCooldown { + HStack { + Image(systemName: "clock") + .foregroundColor(.gray) + Text("Chain Cooldown") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + private var timeoutColor: Color { + if chain.timeoutRemaining < 60 { + return .red + } else if chain.timeoutRemaining < 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) + } +} diff --git a/MacTorn/MacTorn/Views/Components/EventsView.swift b/MacTorn/MacTorn/Views/Components/EventsView.swift new file mode 100644 index 0000000..ad9e31d --- /dev/null +++ b/MacTorn/MacTorn/Views/Components/EventsView.swift @@ -0,0 +1,53 @@ +import SwiftUI + +struct EventsView: View { + let events: [TornEvent] + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Image(systemName: "bell.fill") + .foregroundColor(.blue) + Text("Recent Events") + .font(.caption.bold()) + } + + if events.isEmpty { + Text("No recent events") + .font(.caption2) + .foregroundColor(.secondary) + } else { + ForEach(events.prefix(5)) { event in + HStack(alignment: .top, spacing: 6) { + Text("•") + .foregroundColor(.blue) + Text(event.cleanEvent) + .font(.caption2) + .lineLimit(2) + Spacer() + Text(timeAgo(event.timestamp)) + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + } + .padding(8) + .background(Color.blue.opacity(0.05)) + .cornerRadius(8) + } + + private func timeAgo(_ timestamp: Int) -> String { + let now = Int(Date().timeIntervalSince1970) + let diff = now - timestamp + + if diff < 60 { + return "now" + } else if diff < 3600 { + return "\(diff / 60)m" + } else if diff < 86400 { + return "\(diff / 3600)h" + } + return "\(diff / 86400)d" + } +} diff --git a/MacTorn/MacTorn/Views/Components/StatusBadgesView.swift b/MacTorn/MacTorn/Views/Components/StatusBadgesView.swift new file mode 100644 index 0000000..e6a9590 --- /dev/null +++ b/MacTorn/MacTorn/Views/Components/StatusBadgesView.swift @@ -0,0 +1,54 @@ +import SwiftUI + +struct StatusBadgesView: View { + let status: Status + + var body: some View { + if !status.isOkay { + HStack(spacing: 8) { + if status.isInHospital { + HStack(spacing: 4) { + Image(systemName: "cross.circle.fill") + .foregroundColor(.red) + Text("Hospital") + .font(.caption.bold()) + Text(formatTime(status.timeRemaining)) + .font(.caption.monospacedDigit()) + .foregroundColor(.secondary) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.red.opacity(0.1)) + .cornerRadius(6) + } + + if status.isInJail { + HStack(spacing: 4) { + Image(systemName: "lock.fill") + .foregroundColor(.orange) + Text("Jail") + .font(.caption.bold()) + Text(formatTime(status.timeRemaining)) + .font(.caption.monospacedDigit()) + .foregroundColor(.secondary) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.orange.opacity(0.1)) + .cornerRadius(6) + } + } + } + } + + private func formatTime(_ seconds: Int) -> String { + if seconds <= 0 { return "0:00" } + let hours = seconds / 3600 + let mins = (seconds % 3600) / 60 + let secs = seconds % 60 + if hours > 0 { + return String(format: "%d:%02d:%02d", hours, mins, secs) + } + return String(format: "%d:%02d", mins, secs) + } +}