From 0a0f109fa1c3cfffee394f06b3c70a11d50d9af2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Sat, 17 Jan 2026 17:57:45 +0000 Subject: [PATCH] Initial commit --- .DS_Store | Bin 0 -> 6148 bytes .gitattributes | 2 + .gitignore | 62 +++ LICENSE | 21 + MacTorn/.DS_Store | Bin 0 -> 6148 bytes MacTorn/MacTorn.xcodeproj/project.pbxproj | 396 ++++++++++++++++++ .../AccentColor.colorset/Contents.json | 38 ++ .../AppIcon.appiconset/Contents.json | 58 +++ MacTorn/MacTorn/Assets.xcassets/Contents.json | 6 + MacTorn/MacTorn/Info.plist | 24 ++ MacTorn/MacTorn/MacTornApp.swift | 29 ++ MacTorn/MacTorn/Models/TornModels.swift | 145 +++++++ .../Utilities/LaunchAtLoginManager.swift | 30 ++ .../Utilities/NotificationManager.swift | 39 ++ .../MacTorn/Utilities/ShortcutsManager.swift | 46 ++ MacTorn/MacTorn/ViewModels/AppState.swift | 155 +++++++ .../Views/Components/ProgressBarView.swift | 57 +++ MacTorn/MacTorn/Views/ContentView.swift | 40 ++ MacTorn/MacTorn/Views/SettingsView.swift | 40 ++ MacTorn/MacTorn/Views/StatusView.swift | 142 +++++++ README.md | 2 + 21 files changed, 1332 insertions(+) create mode 100644 .DS_Store create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 MacTorn/.DS_Store create mode 100644 MacTorn/MacTorn.xcodeproj/project.pbxproj create mode 100644 MacTorn/MacTorn/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 MacTorn/MacTorn/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 MacTorn/MacTorn/Assets.xcassets/Contents.json create mode 100644 MacTorn/MacTorn/Info.plist create mode 100644 MacTorn/MacTorn/MacTornApp.swift create mode 100644 MacTorn/MacTorn/Models/TornModels.swift create mode 100644 MacTorn/MacTorn/Utilities/LaunchAtLoginManager.swift create mode 100644 MacTorn/MacTorn/Utilities/NotificationManager.swift create mode 100644 MacTorn/MacTorn/Utilities/ShortcutsManager.swift create mode 100644 MacTorn/MacTorn/ViewModels/AppState.swift create mode 100644 MacTorn/MacTorn/Views/Components/ProgressBarView.swift create mode 100644 MacTorn/MacTorn/Views/ContentView.swift create mode 100644 MacTorn/MacTorn/Views/SettingsView.swift create mode 100644 MacTorn/MacTorn/Views/StatusView.swift create mode 100644 README.md diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..5d6bdfba9a19b8bcb67e7838582a9368297d0802 GIT binary patch literal 6148 zcmeHKyJ`bL3>?LV7~Hr_xxbJmk-v1xyzs&#VB<-Yt6!=#P*nG3wtocgSTPH8)y|&RG>0a|mcjG!J4AG8>(T=(C fc6=8_S=W5c^IkY62A%n!6ZJFTy2zx!Un}qhz3UZr literal 0 HcmV?d00001 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..52fe2f7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,62 @@ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +# *.xcodeproj +# +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +# .swiftpm + +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..07028ca --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Paweł Orzech + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MacTorn/.DS_Store b/MacTorn/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..745f1693247dce0e6b494d7987a3638402ef95cf GIT binary patch literal 6148 zcmeHKOG*SW5Pg}MMT47h=Sp${VQ$b4?b6kF0PSf}WX3dt;4&xhHlD!~co|o|N|iE= zy%=1GNEIZnI;oe>gHBQa!t$|O0CNBpi(n9E#H4w&YhuAOVkl!IJm4NDc*1&QpugB8 zYd^#}uDIe0?5;nd!!>&DDB*5%vskUuw%0ARZ~L*fscG6xY8hRW=`kk3b+EUfGgk%3>4sUdhH6h0tTH z3QYBLF8BW#f0@xFe@O8!SHKncX9|Q_vuc*yl-;f0wkLOOWVvJ!lejDnjsD;fzz;b` fj!LKYNAVd~8)iirMcNCU=nsJ+h*z$_FDUQ + + + + LSUIElement + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/MacTorn/MacTorn/MacTornApp.swift b/MacTorn/MacTorn/MacTornApp.swift new file mode 100644 index 0000000..911d724 --- /dev/null +++ b/MacTorn/MacTorn/MacTornApp.swift @@ -0,0 +1,29 @@ +import SwiftUI + +@main +struct MacTornApp: App { + @StateObject private var appState = AppState() + + var body: some Scene { + MenuBarExtra { + ContentView() + .environmentObject(appState) + } label: { + Image(systemName: menuBarIcon) + .renderingMode(.template) + } + .menuBarExtraStyle(.window) + } + + private var menuBarIcon: String { + if appState.errorMsg != nil { + return "exclamationmark.triangle.fill" + } + if let bars = appState.data?.bars { + if bars.energy.current >= bars.energy.maximum { + return "bolt.fill" + } + } + return "bolt" + } +} diff --git a/MacTorn/MacTorn/Models/TornModels.swift b/MacTorn/MacTorn/Models/TornModels.swift new file mode 100644 index 0000000..6a2e6dc --- /dev/null +++ b/MacTorn/MacTorn/Models/TornModels.swift @@ -0,0 +1,145 @@ +import Foundation + +// MARK: - Root Response +struct TornResponse: Codable { + let bars: Bars? + let cooldowns: Cooldowns? + let travel: Travel? + let error: TornError? +} + +// MARK: - Bars +struct Bar: Codable, Equatable { + let current: Int + let maximum: Int + let increment: Double + let interval: Int + let ticktime: Int + let fulltime: Int +} + +struct Bars: Codable, Equatable { + let energy: Bar + let nerve: Bar + let life: Bar + let happy: Bar +} + +// MARK: - Cooldowns +struct Cooldowns: Codable, Equatable { + let drug: Int + let medical: Int + let booster: Int +} + +// MARK: - Travel +struct Travel: Codable, Equatable { + let destination: String + let timestamp: Int + let departed: Int + let timeLeft: Int + + enum CodingKeys: String, CodingKey { + case destination + case timestamp + case departed + case timeLeft = "time_left" + } + + var isAbroad: Bool { + destination != "Torn" && timeLeft == 0 + } + + var isTraveling: Bool { + timeLeft > 0 + } + + var arrivalDate: Date? { + guard isTraveling else { return nil } + return Date(timeIntervalSince1970: TimeInterval(timestamp)) + } +} + +// MARK: - Error +struct TornError: Codable { + let code: Int + let error: String +} + +// MARK: - API Configuration +enum TornAPI { + static let baseURL = "https://api.torn.com/user/" + static let selections = "bars,cooldowns,travel" + + static func url(for apiKey: String) -> URL? { + URL(string: "\(baseURL)?selections=\(selections)&key=\(apiKey)") + } +} + +// MARK: - Keyboard Shortcuts +struct KeyboardShortcut: Identifiable, Codable, Equatable { + let id: String + var name: String + var url: String + var keyEquivalent: String + 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"] + ) + ] +} diff --git a/MacTorn/MacTorn/Utilities/LaunchAtLoginManager.swift b/MacTorn/MacTorn/Utilities/LaunchAtLoginManager.swift new file mode 100644 index 0000000..270c800 --- /dev/null +++ b/MacTorn/MacTorn/Utilities/LaunchAtLoginManager.swift @@ -0,0 +1,30 @@ +import Foundation +import ServiceManagement + +@MainActor +class LaunchAtLoginManager: ObservableObject { + @Published var isEnabled: Bool = false + + private let service = SMAppService.mainApp + + init() { + updateStatus() + } + + func updateStatus() { + isEnabled = service.status == .enabled + } + + func toggle() { + do { + if isEnabled { + try service.unregister() + } else { + try service.register() + } + updateStatus() + } catch { + print("Launch at Login error: \(error)") + } + } +} diff --git a/MacTorn/MacTorn/Utilities/NotificationManager.swift b/MacTorn/MacTorn/Utilities/NotificationManager.swift new file mode 100644 index 0000000..ae131bf --- /dev/null +++ b/MacTorn/MacTorn/Utilities/NotificationManager.swift @@ -0,0 +1,39 @@ +import Foundation +import UserNotifications + +class NotificationManager { + static let shared = NotificationManager() + + private init() {} + + func requestPermission() async { + do { + let granted = try await UNUserNotificationCenter.current() + .requestAuthorization(options: [.alert, .sound, .badge]) + if granted { + print("Notification permission granted") + } + } catch { + print("Notification permission error: \(error)") + } + } + + func send(title: String, body: String) { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + + let request = UNNotificationRequest( + identifier: UUID().uuidString, + content: content, + trigger: nil // Immediate + ) + + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + print("Notification error: \(error)") + } + } + } +} diff --git a/MacTorn/MacTorn/Utilities/ShortcutsManager.swift b/MacTorn/MacTorn/Utilities/ShortcutsManager.swift new file mode 100644 index 0000000..4d02bdb --- /dev/null +++ b/MacTorn/MacTorn/Utilities/ShortcutsManager.swift @@ -0,0 +1,46 @@ +import Foundation +import SwiftUI + +@MainActor +class ShortcutsManager: ObservableObject { + @Published var shortcuts: [KeyboardShortcut] = [] + + private let storageKey = "customShortcuts" + + init() { + loadShortcuts() + } + + func loadShortcuts() { + if let data = UserDefaults.standard.data(forKey: storageKey), + let saved = try? JSONDecoder().decode([KeyboardShortcut].self, from: data) { + shortcuts = saved + } else { + shortcuts = KeyboardShortcut.defaults + saveShortcuts() + } + } + + func saveShortcuts() { + if let data = try? JSONEncoder().encode(shortcuts) { + UserDefaults.standard.set(data, forKey: storageKey) + } + } + + func updateShortcut(_ shortcut: KeyboardShortcut) { + if let index = shortcuts.firstIndex(where: { $0.id == shortcut.id }) { + shortcuts[index] = shortcut + saveShortcuts() + } + } + + func resetToDefaults() { + shortcuts = KeyboardShortcut.defaults + saveShortcuts() + } + + func openURL(_ urlString: String) { + guard let url = URL(string: urlString) else { return } + NSWorkspace.shared.open(url) + } +} diff --git a/MacTorn/MacTorn/ViewModels/AppState.swift b/MacTorn/MacTorn/ViewModels/AppState.swift new file mode 100644 index 0000000..ba65f82 --- /dev/null +++ b/MacTorn/MacTorn/ViewModels/AppState.swift @@ -0,0 +1,155 @@ +import Foundation +import Combine +import SwiftUI + +@MainActor +class AppState: ObservableObject { + // MARK: - Persisted + @AppStorage("apiKey") var apiKey: String = "" + + // MARK: - Published State + @Published var data: TornResponse? + @Published var lastUpdated: Date? + @Published var errorMsg: String? + @Published var isLoading: Bool = false + + // MARK: - State Comparison + private var previousBars: Bars? + private var previousCooldowns: Cooldowns? + + // MARK: - Timer + private var timerCancellable: AnyCancellable? + + init() { + startPolling() + Task { + await NotificationManager.shared.requestPermission() + } + } + + func startPolling() { + // Initial fetch + fetchData() + + // Set up 30-second polling + timerCancellable = Timer.publish(every: 30, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + self?.fetchData() + } + } + + func stopPolling() { + timerCancellable?.cancel() + timerCancellable = nil + } + + func refreshNow() { + fetchData() + } + + func fetchData() { + guard !apiKey.isEmpty else { + errorMsg = "API Key required" + return + } + + guard let url = TornAPI.url(for: apiKey) else { + errorMsg = "Invalid URL" + return + } + + isLoading = true + errorMsg = nil + + Task { + do { + let (data, response) = try await URLSession.shared.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.invalidResponse + } + + switch httpResponse.statusCode { + case 200: + let decoded = try JSONDecoder().decode(TornResponse.self, from: data) + + if let error = decoded.error { + self.errorMsg = "API Error: \(error.error)" + self.data = nil + } else { + // Check for notifications before updating + checkNotifications(newData: decoded) + + self.data = decoded + self.lastUpdated = Date() + self.errorMsg = nil + + // Store for comparison + self.previousBars = decoded.bars + self.previousCooldowns = decoded.cooldowns + } + case 403, 404: + self.errorMsg = "Invalid API Key" + self.data = nil + default: + self.errorMsg = "HTTP Error: \(httpResponse.statusCode)" + } + } catch { + self.errorMsg = error.localizedDescription + } + + self.isLoading = false + } + } + + private func checkNotifications(newData: TornResponse) { + guard let prev = previousBars, let current = newData.bars else { return } + + // Energy full notification + if prev.energy.current < prev.energy.maximum && + current.energy.current >= current.energy.maximum { + NotificationManager.shared.send( + title: "Energy Full! ⚡️", + body: "Your energy bar is now full (\(current.energy.maximum)/\(current.energy.maximum))" + ) + } + + // Nerve full notification + if prev.nerve.current < prev.nerve.maximum && + current.nerve.current >= current.nerve.maximum { + NotificationManager.shared.send( + title: "Nerve Full! 💪", + body: "Your nerve bar is now full (\(current.nerve.maximum)/\(current.nerve.maximum))" + ) + } + + // 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" + ) + } + if prevCD.medical > 0 && currentCD.medical == 0 { + NotificationManager.shared.send( + title: "Medical Ready! 🏥", + body: "Medical cooldown has ended" + ) + } + if prevCD.booster > 0 && currentCD.booster == 0 { + NotificationManager.shared.send( + title: "Booster Ready! 🚀", + body: "Booster cooldown has ended" + ) + } + } + } +} + +// MARK: - Errors +enum APIError: Error { + case invalidResponse + case invalidData +} diff --git a/MacTorn/MacTorn/Views/Components/ProgressBarView.swift b/MacTorn/MacTorn/Views/Components/ProgressBarView.swift new file mode 100644 index 0000000..f175148 --- /dev/null +++ b/MacTorn/MacTorn/Views/Components/ProgressBarView.swift @@ -0,0 +1,57 @@ +import SwiftUI + +struct ProgressBarView: View { + let label: String + let current: Int + let maximum: Int + let color: Color + let icon: String + + private var progress: Double { + guard maximum > 0 else { return 0 } + return Double(current) / Double(maximum) + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Image(systemName: icon) + .foregroundColor(color) + .font(.caption) + + Text(label) + .font(.caption.bold()) + + Spacer() + + Text("\(current)/\(maximum)") + .font(.caption.monospacedDigit()) + .foregroundColor(.secondary) + } + + GeometryReader { geometry in + ZStack(alignment: .leading) { + // Background + RoundedRectangle(cornerRadius: 4) + .fill(color.opacity(0.2)) + + // Foreground + RoundedRectangle(cornerRadius: 4) + .fill(color) + .frame(width: geometry.size.width * progress) + } + } + .frame(height: 8) + } + } +} + +#Preview { + VStack(spacing: 16) { + ProgressBarView(label: "Energy", current: 75, maximum: 100, color: .green, icon: "bolt.fill") + ProgressBarView(label: "Nerve", current: 25, maximum: 50, color: .red, icon: "flame.fill") + ProgressBarView(label: "Happy", current: 1000, maximum: 1000, color: .yellow, icon: "face.smiling.fill") + } + .padding() + .frame(width: 280) +} diff --git a/MacTorn/MacTorn/Views/ContentView.swift b/MacTorn/MacTorn/Views/ContentView.swift new file mode 100644 index 0000000..b5b1812 --- /dev/null +++ b/MacTorn/MacTorn/Views/ContentView.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct ContentView: View { + @EnvironmentObject var appState: AppState + + var body: some View { + VStack(spacing: 0) { + if appState.apiKey.isEmpty { + SettingsView() + } else { + StatusView() + } + + Divider() + .padding(.vertical, 8) + + // Footer buttons + HStack { + Button("Settings") { + appState.apiKey = "" // Go back to settings + } + .buttonStyle(.plain) + .foregroundColor(.secondary) + + Spacer() + + Button("Quit") { + NSApplication.shared.terminate(nil) + } + .buttonStyle(.plain) + .foregroundColor(.secondary) + } + .font(.caption) + .padding(.horizontal) + .padding(.bottom, 8) + } + .frame(width: 280) + .environmentObject(appState) + } +} diff --git a/MacTorn/MacTorn/Views/SettingsView.swift b/MacTorn/MacTorn/Views/SettingsView.swift new file mode 100644 index 0000000..55dd0f0 --- /dev/null +++ b/MacTorn/MacTorn/Views/SettingsView.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct SettingsView: View { + @EnvironmentObject var appState: AppState + @State private var inputKey: String = "" + + var body: some View { + VStack(spacing: 16) { + Image(systemName: "bolt.circle.fill") + .font(.system(size: 48)) + .foregroundColor(.orange) + + Text("MacTorn") + .font(.title2.bold()) + + Text("Enter your Torn API Key") + .font(.caption) + .foregroundColor(.secondary) + + SecureField("API Key", text: $inputKey) + .textFieldStyle(.roundedBorder) + .padding(.horizontal) + + Button("Save & Connect") { + appState.apiKey = inputKey.trimmingCharacters(in: .whitespacesAndNewlines) + appState.refreshNow() + } + .buttonStyle(.borderedProminent) + .disabled(inputKey.isEmpty) + + Link("Get API Key from Torn", + destination: URL(string: "https://www.torn.com/preferences.php#tab=api")!) + .font(.caption) + } + .padding() + .onAppear { + inputKey = appState.apiKey + } + } +} diff --git a/MacTorn/MacTorn/Views/StatusView.swift b/MacTorn/MacTorn/Views/StatusView.swift new file mode 100644 index 0000000..fea38d4 --- /dev/null +++ b/MacTorn/MacTorn/Views/StatusView.swift @@ -0,0 +1,142 @@ +import SwiftUI + +struct StatusView: View { + @EnvironmentObject var appState: AppState + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Header + HStack { + Text("Torn Status") + .font(.headline) + + Spacer() + + if appState.isLoading { + ProgressView() + .scaleEffect(0.6) + } else { + Button { + appState.refreshNow() + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.plain) + .foregroundColor(.secondary) + } + } + + // Last updated + if let lastUpdated = appState.lastUpdated { + Text("Updated: \(lastUpdated, formatter: timeFormatter)") + .font(.caption2) + .foregroundColor(.secondary) + } + + // Error state + if let error = appState.errorMsg { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + Text(error) + .font(.caption) + .foregroundColor(.secondary) + } + .padding(.vertical, 4) + } + + // Bars + if let bars = appState.data?.bars { + VStack(spacing: 8) { + ProgressBarView( + label: "Energy", + current: bars.energy.current, + maximum: bars.energy.maximum, + color: .green, + icon: "bolt.fill" + ) + + ProgressBarView( + label: "Nerve", + current: bars.nerve.current, + maximum: bars.nerve.maximum, + color: .red, + icon: "flame.fill" + ) + + ProgressBarView( + label: "Happy", + current: bars.happy.current, + maximum: bars.happy.maximum, + color: .yellow, + icon: "face.smiling.fill" + ) + + ProgressBarView( + label: "Life", + current: bars.life.current, + maximum: bars.life.maximum, + color: .pink, + icon: "heart.fill" + ) + } + } + + // Cooldowns + if let cooldowns = appState.data?.cooldowns { + Divider() + .padding(.vertical, 4) + + Text("Cooldowns") + .font(.caption.bold()) + .foregroundColor(.secondary) + + HStack(spacing: 16) { + CooldownItem(label: "Drug", seconds: cooldowns.drug, icon: "pills.fill") + CooldownItem(label: "Medical", seconds: cooldowns.medical, icon: "cross.case.fill") + CooldownItem(label: "Booster", seconds: cooldowns.booster, icon: "arrow.up.circle.fill") + } + } + } + .padding() + } + + private var timeFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.timeStyle = .short + return formatter + } +} + +// MARK: - Cooldown Item +struct CooldownItem: View { + let label: String + let seconds: Int + let icon: String + + var body: some View { + VStack(spacing: 2) { + Image(systemName: icon) + .foregroundColor(seconds > 0 ? .orange : .green) + + Text(formattedTime) + .font(.caption2.monospacedDigit()) + .foregroundColor(seconds > 0 ? .primary : .green) + } + .frame(maxWidth: .infinity) + } + + private var formattedTime: String { + if seconds <= 0 { + return "Ready" + } + let minutes = seconds / 60 + let secs = seconds % 60 + if minutes >= 60 { + let hours = minutes / 60 + let mins = minutes % 60 + return String(format: "%d:%02d:%02d", hours, mins, secs) + } + return String(format: "%d:%02d", minutes, secs) + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..df8add9 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# MacTorn +Torn notifier app for macOS