Add status, chain, events, and sound features
Introduces new models for status, chain, events, and messages in TornModels.swift, along with notification rules and sound options. Adds a SoundManager utility for playing notification sounds. Implements new SwiftUI components: ChainView, EventsView, and StatusBadgesView for displaying chain progress, recent events, and status badges. Updates app icon assets and Contents.json for proper macOS icon support.
|
|
@ -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"
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 1 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 385 KiB |
|
|
@ -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
|
||||
}
|
||||
|
||||
// MARK: - Bars (for internal use)
|
||||
// Recent events sorted
|
||||
var recentEvents: [TornEvent] {
|
||||
guard let events = events else { return [] }
|
||||
return events.values.sorted { $0.timestamp > $1.timestamp }
|
||||
}
|
||||
}
|
||||
|
||||
// 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"])
|
||||
]
|
||||
}
|
||||
|
|
|
|||
34
MacTorn/MacTorn/Utilities/SoundManager.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
50
MacTorn/MacTorn/Views/Components/ChainView.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
53
MacTorn/MacTorn/Views/Components/EventsView.swift
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
54
MacTorn/MacTorn/Views/Components/StatusBadgesView.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||