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": [
|
"images": [
|
||||||
{
|
{
|
||||||
|
"filename": "icon_16x16.png",
|
||||||
"idiom": "mac",
|
"idiom": "mac",
|
||||||
"scale": "1x",
|
"scale": "1x",
|
||||||
"size": "16x16"
|
"size": "16x16"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename": "icon_16x16@2x.png",
|
||||||
"idiom": "mac",
|
"idiom": "mac",
|
||||||
"scale": "2x",
|
"scale": "2x",
|
||||||
"size": "16x16"
|
"size": "16x16"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename": "icon_32x32.png",
|
||||||
"idiom": "mac",
|
"idiom": "mac",
|
||||||
"scale": "1x",
|
"scale": "1x",
|
||||||
"size": "32x32"
|
"size": "32x32"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename": "icon_32x32@2x.png",
|
||||||
"idiom": "mac",
|
"idiom": "mac",
|
||||||
"scale": "2x",
|
"scale": "2x",
|
||||||
"size": "32x32"
|
"size": "32x32"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename": "icon_128x128.png",
|
||||||
"idiom": "mac",
|
"idiom": "mac",
|
||||||
"scale": "1x",
|
"scale": "1x",
|
||||||
"size": "128x128"
|
"size": "128x128"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename": "icon_128x128@2x.png",
|
||||||
"idiom": "mac",
|
"idiom": "mac",
|
||||||
"scale": "2x",
|
"scale": "2x",
|
||||||
"size": "128x128"
|
"size": "128x128"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename": "icon_256x256.png",
|
||||||
"idiom": "mac",
|
"idiom": "mac",
|
||||||
"scale": "1x",
|
"scale": "1x",
|
||||||
"size": "256x256"
|
"size": "256x256"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename": "icon_256x256@2x.png",
|
||||||
"idiom": "mac",
|
"idiom": "mac",
|
||||||
"scale": "2x",
|
"scale": "2x",
|
||||||
"size": "256x256"
|
"size": "256x256"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename": "icon_512x512.png",
|
||||||
"idiom": "mac",
|
"idiom": "mac",
|
||||||
"scale": "1x",
|
"scale": "1x",
|
||||||
"size": "512x512"
|
"size": "512x512"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"filename": "icon_512x512@2x.png",
|
||||||
"idiom": "mac",
|
"idiom": "mac",
|
||||||
"scale": "2x",
|
"scale": "2x",
|
||||||
"size": "512x512"
|
"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 happy: Bar?
|
||||||
let cooldowns: Cooldowns?
|
let cooldowns: Cooldowns?
|
||||||
let travel: Travel?
|
let travel: Travel?
|
||||||
|
let status: Status?
|
||||||
|
let chain: Chain?
|
||||||
|
let events: [String: TornEvent]?
|
||||||
|
let messages: [String: TornMessage]?
|
||||||
let error: TornError?
|
let error: TornError?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case name
|
case name
|
||||||
case playerId = "player_id"
|
case playerId = "player_id"
|
||||||
case energy, nerve, life, happy
|
case energy, nerve, life, happy
|
||||||
case cooldowns, travel, error
|
case cooldowns, travel, status, chain
|
||||||
|
case events, messages, error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convenience computed property
|
// Convenience computed property
|
||||||
|
|
@ -27,9 +32,20 @@ struct TornResponse: Codable {
|
||||||
let happy = happy else { return nil }
|
let happy = happy else { return nil }
|
||||||
return Bars(energy: energy, nerve: nerve, life: life, happy: happy)
|
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 {
|
struct Bar: Codable, Equatable {
|
||||||
let current: Int
|
let current: Int
|
||||||
let maximum: Int
|
let maximum: Int
|
||||||
|
|
@ -46,6 +62,11 @@ struct Bar: Codable, Equatable {
|
||||||
self.ticktime = ticktime
|
self.ticktime = ticktime
|
||||||
self.fulltime = fulltime
|
self.fulltime = fulltime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var percentage: Double {
|
||||||
|
guard maximum > 0 else { return 0 }
|
||||||
|
return Double(current) / Double(maximum) * 100
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Bars: Equatable {
|
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
|
// MARK: - Error
|
||||||
struct TornError: Codable {
|
struct TornError: Codable {
|
||||||
let code: Int
|
let code: Int
|
||||||
|
|
@ -99,13 +191,60 @@ struct TornError: Codable {
|
||||||
// MARK: - API Configuration
|
// MARK: - API Configuration
|
||||||
enum TornAPI {
|
enum TornAPI {
|
||||||
static let baseURL = "https://api.torn.com/user/"
|
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? {
|
static func url(for apiKey: String) -> URL? {
|
||||||
URL(string: "\(baseURL)?selections=\(selections)&key=\(apiKey)")
|
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
|
// MARK: - Keyboard Shortcuts
|
||||||
struct KeyboardShortcut: Identifiable, Codable, Equatable {
|
struct KeyboardShortcut: Identifiable, Codable, Equatable {
|
||||||
let id: String
|
let id: String
|
||||||
|
|
@ -115,61 +254,13 @@ struct KeyboardShortcut: Identifiable, Codable, Equatable {
|
||||||
var modifiers: [String]
|
var modifiers: [String]
|
||||||
|
|
||||||
static let defaults: [KeyboardShortcut] = [
|
static let defaults: [KeyboardShortcut] = [
|
||||||
KeyboardShortcut(
|
KeyboardShortcut(id: "home", name: "Home", url: "https://www.torn.com/", keyEquivalent: "h", modifiers: ["command", "shift"]),
|
||||||
id: "home",
|
KeyboardShortcut(id: "items", name: "Items", url: "https://www.torn.com/item.php", keyEquivalent: "i", modifiers: ["command", "shift"]),
|
||||||
name: "Home",
|
KeyboardShortcut(id: "gym", name: "Gym", url: "https://www.torn.com/gym.php", keyEquivalent: "g", modifiers: ["command", "shift"]),
|
||||||
url: "https://www.torn.com/",
|
KeyboardShortcut(id: "crimes", name: "Crimes", url: "https://www.torn.com/crimes.php", keyEquivalent: "c", modifiers: ["command", "shift"]),
|
||||||
keyEquivalent: "h",
|
KeyboardShortcut(id: "mission", name: "Missions", url: "https://www.torn.com/missions.php", keyEquivalent: "m", modifiers: ["command", "shift"]),
|
||||||
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(
|
KeyboardShortcut(id: "faction", name: "Faction", url: "https://www.torn.com/factions.php", keyEquivalent: "f", modifiers: ["command", "shift"])
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||