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.
This commit is contained in:
Paweł Orzech 2026-01-17 19:36:55 +00:00
parent 974556b24c
commit 01b0b9a436
No known key found for this signature in database
16 changed files with 351 additions and 59 deletions

View file

@ -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"

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

View file

@ -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"])
]
}

View 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)
}
}
}
}

View 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)
}
}

View 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"
}
}

View 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)
}
}