Compare commits

...

24 commits
v1.3 ... main

Author SHA1 Message Date
Paweł Orzech
3e214a0b19
Add feedback prompt and release v1.4.7
Add in-app feedback prompt with progressive timing thresholds
(1 hour, 1 week, 1 month) and 5-minute cooldown. Includes
FeedbackPromptView, AppFeedbackState model, and comprehensive
test coverage.
2026-01-27 23:43:17 +01:00
Paweł Orzech
b1804e5a69
fix: Prevent incorrect "Released" notification on travel arrival
The "Released! 🎉 - You are now free" notification was incorrectly
triggering when landing from airplane travel. Changed condition to
only fire when transitioning from Hospital or Jail status, not any
non-Okay state like Traveling.

Bumps version to 1.4.6.
2026-01-25 15:57:49 +01:00
Paweł Orzech
63cef35384
Add new version release instructions and v1.4.5 zip
Added a markdown file with instructions for creating a new version release, including changelog and version updates. Also added the MacTorn-v1.4.5.zip file for direct distribution.
2026-01-25 12:07:44 +01:00
Paweł Orzech
e4c8f6927b
fix: Improve travel timer accuracy with direct timestamp usage
- Use API timestamp directly for travel countdown calculations
- Add fallback to timeLeft for backward compatibility
- Add comprehensive test coverage for remainingSeconds method
- Bump version to 1.4.5
- Add CHANGELOG.md
2026-01-25 12:02:56 +01:00
Paweł Orzech
a55be3c6be
Update README.md 2026-01-20 13:31:42 +00:00
Paweł Orzech
a2d3e6416f
docs: Migrate wiki to GitHub Wiki feature
Move documentation from wiki/ folder to GitHub Wiki repository.
Add wiki link to README for easy access.
2026-01-20 13:30:20 +00:00
Paweł Orzech
715f0877ff
docs: Add comprehensive GitHub wiki documentation
Create wiki/ directory with 11 markdown pages covering:
- Home, Installation, Getting Started guides
- Features documentation for all tabs
- API Setup with permissions and security
- Configuration options and settings
- Troubleshooting and FAQ
- Development guide with architecture overview
- Changelog with version history
- Sidebar navigation
2026-01-20 13:24:55 +00:00
Paweł Orzech
e10add9474
fix: Resolve Swift concurrency errors by extracting MainActor functions
Extract watchlist mutations into dedicated @MainActor functions to avoid
actor-isolated property access issues in async context. This fixes CI
build failures caused by strict concurrency checking.
2026-01-20 13:22:26 +00:00
Paweł Orzech
9724bcbacb
Fix watchlist item mutation to update via copy
Refactored watchlist item updates to use value semantics by copying, modifying, and reassigning the item in the array. This prevents issues with direct mutation of value types in Swift arrays and ensures changes are properly reflected.
2026-01-20 13:18:42 +00:00
Paweł Orzech
8a4fb30cad
chore: Remove root .DS_Store from git tracking 2026-01-20 13:12:19 +00:00
Paweł Orzech
4414a2696a
chore: Remove .DS_Store from git tracking 2026-01-20 13:10:25 +00:00
Paweł Orzech
21ac399269
feat: Improve accessibility support and update README
- Fix Reduce Transparency mode in light mode (dark buttons issue)
- Lower opacity values for better readability when reduceTransparency is enabled
- Add Accessibility section to README documenting macOS accessibility support
- Update README with new light/dark mode screenshots
- Bump version to 1.4.4
2026-01-20 13:00:11 +00:00
Paweł Orzech
273fd31884
feat: Add Universal Binary support for Intel and Apple Silicon Macs
- Add ARCHS="arm64 x86_64" to Release configuration for universal builds
- Update Info.plist to use dynamic version variables (MARKETING_VERSION, CURRENT_PROJECT_VERSION)
- Update Makefile release target with proper universal build settings
- Add Universal Binary badge to README
2026-01-19 16:46:03 +00:00
Paweł Orzech
7f836a0bbd
feat: Display cooldown labels as text instead of icons
Show "Drug", "Medical", "Booster" text labels in cooldowns section
for better clarity. Bump version to 1.4.3.
2026-01-19 16:16:53 +00:00
Paweł Orzech
7cca5dd896
chore: Bump version to 1.4.2 2026-01-18 21:44:05 +00:00
Paweł Orzech
f9a2b63ab4
fix: Resolve Credits view height being cut off in MenuBarExtra
- Remove Spacer() that was compressing ScrollView
- Set fixed height (480) to ensure all sections are visible
2026-01-18 21:43:44 +00:00
Paweł Orzech
7c4059e59a
chore: Remove old MacTorn-v1.3.zip archive 2026-01-18 21:29:11 +00:00
Paweł Orzech
57be14f6c3
chore: Bump version to 1.4.1 2026-01-18 21:27:43 +00:00
Paweł Orzech
0764a38bb8
fix: Resolve SwiftUI constraint update loop in MenuBarExtra
- Add .transaction { $0.animation = nil } to prevent constraint recalculation loops
- Add CreditsView.swift to Xcode project (was missing after git pull)
- Fixes NSGenericException for Update Constraints in Window passes
2026-01-18 21:25:49 +00:00
Paweł Orzech
71db6d4db1
feat: Add Travel tab with live countdown timer and pre-arrival notifications
- New Travel tab with flight status, progress bar, and quick destination picker
- Live countdown timer in menu bar during flight (✈️🇺🇸 5:32 format)
- Pre-arrival notifications (configurable: 2min, 1min, 30sec, 10sec before landing)
- Country flag emojis for all 11 Torn destinations
- Live countdown updates every second in both menu bar and app views
- Bump version to 1.4
2026-01-18 21:22:50 +00:00
Paweł Orzech
2e485c05d1
Merge pull request #1 from pawelorzech/claude/add-credits-page-NhUe5
Add credits page with username linking
2026-01-18 19:47:08 +00:00
Claude
bab929df98
feat: Populate Credits page with real data
- Add bombel as developer/creator with highlighted section
- Add Special Thanks: kaszmir, dylanwishop, constanziagatta
- Add faction link: The Masters
- Add company link: Glory Holes Productions
- All entries link to respective Torn pages
2026-01-18 19:43:35 +00:00
Claude
14439f50ff
feat: Add Credits page accessible from Settings
- Create CreditsView with placeholder contributors
- Contributors can have Torn profile links (auto-generated from ID)
- Support for multiple sections (Special Thanks, Beta Testers)
- Navigate to Credits via heart icon button in Settings footer
2026-01-18 16:28:52 +00:00
Paweł Orzech
e781a767b3
Update .DS_Store 2026-01-18 12:50:13 +00:00
39 changed files with 1902 additions and 169 deletions

BIN
.DS_Store vendored

Binary file not shown.

View file

@ -0,0 +1,15 @@
---
allowed-tools: Bash(git add:*), Bash(git status:*), Bash(git commit:*)
description: Create version for direct distribution. Update changelog, update readme, update version in gradle files, create a git commit and push it to github with release.
---
## Context
- Current git status: !`git status`
- Current git diff (staged and unstaged changes): !`git diff HEAD`
- Current branch: !`git branch --show-current`
- Recent commits: !`git log --oneline -10`
## Your task
Create version for direct distribution. Update changelog, update readme, update version in gradle files, create a git commit and push it to github with release.

3
.gitignore vendored
View file

@ -68,3 +68,6 @@ DerivedData/
# Archive folder (old releases)
archive/
# Claude Code
CLAUDE.md

73
CHANGELOG.md Normal file
View file

@ -0,0 +1,73 @@
# Changelog
All notable changes to MacTorn will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.4.7] - 2026-01-27
### Added
- In-app feedback prompt with smart timing (1 hour, 1 week, 1 month thresholds)
- Positive feedback links to Torn forums thread
- Negative feedback opens email for direct developer contact
- 5-minute cooldown between prompt dismissals
- Comprehensive test coverage for feedback logic
## [1.4.6] - 2025-01-25
### Fixed
- Fixed incorrect "Released" notification triggering when landing from travel
- "Released! 🎉 - You are now free" notification now only fires when released from Hospital or Jail, not when arriving from airplane travel
## [1.4.5] - 2025-01-25
### Fixed
- Improved travel timer accuracy by using API timestamp directly instead of calculating from fetch time offset
- Travel countdown now stays synchronized regardless of network delays or fetch timing
### Added
- Comprehensive test coverage for travel timer calculations
## [1.4.4] - Previous Release
### Fixed
- Resolve Swift concurrency errors by extracting MainActor functions
- Fix watchlist item mutation to update via copy
### Added
- Universal Binary support for Intel and Apple Silicon Macs
- Improved accessibility support
- Display cooldown labels as text instead of icons
## [1.4.3] - Earlier Release
### Added
- GitHub wiki documentation
- Migrated wiki to GitHub Wiki feature
## [1.4.2] - Earlier Release
### Changed
- Various bug fixes and improvements
## [1.4.1] - Earlier Release
### Changed
- Various bug fixes and improvements
## [1.4] - Initial Public Release
### Added
- Native macOS menu bar app for Torn game monitoring
- Status tab with live bars, cooldowns, and travel monitoring
- Travel tab with live countdown timer in menu bar
- Money tab with cash, vault, points display
- Attacks tab with battle stats and recent attacks
- Faction tab with chain status
- Watchlist tab for item price tracking
- Smart notifications for various game events
- Configurable refresh intervals
- Launch at login support
- Light and dark mode support
- Accessibility support with Reduce Transparency

Binary file not shown.

BIN
MacTorn-v1.4.4.zip Normal file

Binary file not shown.

BIN
MacTorn-v1.4.5.zip Normal file

Binary file not shown.

BIN
MacTorn-v1.4.6.zip Normal file

Binary file not shown.

BIN
MacTorn-v1.4.7.zip Normal file

Binary file not shown.

BIN
MacTorn/.DS_Store vendored

Binary file not shown.

View file

@ -28,6 +28,10 @@
AAA00019 /* WatchlistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10020 /* WatchlistView.swift */; };
AAA00020 /* PropertiesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10021 /* PropertiesView.swift */; };
AAA00021 /* NetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10022 /* NetworkSession.swift */; };
AAA00022 /* TravelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10023 /* TravelView.swift */; };
AAA00023 /* CreditsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10024 /* CreditsView.swift */; };
AAA00024 /* TransparencyEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10025 /* TransparencyEnvironment.swift */; };
AAA00025 /* FeedbackPromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10026 /* FeedbackPromptView.swift */; };
/* Unit Tests */
BBB00001 /* MockNetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10001 /* MockNetworkSession.swift */; };
BBB00002 /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10002 /* TestHelpers.swift */; };
@ -41,6 +45,7 @@
BBB00010 /* MoneyDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10010 /* MoneyDataTests.swift */; };
BBB00011 /* AppStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10011 /* AppStateTests.swift */; };
BBB00012 /* AppStateWatchlistTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10012 /* AppStateWatchlistTests.swift */; };
BBB00013 /* AppStateFeedbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10013 /* AppStateFeedbackTests.swift */; };
/* UI Tests */
CCC00001 /* MacTornUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCC10001 /* MacTornUITests.swift */; };
/* End PBXBuildFile section */
@ -85,6 +90,10 @@
AAA10020 /* WatchlistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchlistView.swift; sourceTree = "<group>"; };
AAA10021 /* PropertiesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PropertiesView.swift; sourceTree = "<group>"; };
AAA10022 /* NetworkSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkSession.swift; sourceTree = "<group>"; };
AAA10023 /* TravelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TravelView.swift; sourceTree = "<group>"; };
AAA10024 /* CreditsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreditsView.swift; sourceTree = "<group>"; };
AAA10025 /* TransparencyEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransparencyEnvironment.swift; sourceTree = "<group>"; };
AAA10026 /* FeedbackPromptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackPromptView.swift; sourceTree = "<group>"; };
AAA10000 /* MacTorn.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacTorn.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* Unit Test Files */
BBB10001 /* MockNetworkSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNetworkSession.swift; sourceTree = "<group>"; };
@ -99,6 +108,7 @@
BBB10010 /* MoneyDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoneyDataTests.swift; sourceTree = "<group>"; };
BBB10011 /* AppStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateTests.swift; sourceTree = "<group>"; };
BBB10012 /* AppStateWatchlistTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateWatchlistTests.swift; sourceTree = "<group>"; };
BBB10013 /* AppStateFeedbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateFeedbackTests.swift; sourceTree = "<group>"; };
BBB10000 /* MacTornTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MacTornTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* UI Test Files */
CCC10001 /* MacTornUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacTornUITests.swift; sourceTree = "<group>"; };
@ -151,6 +161,7 @@
AAA30005 /* Views */,
AAA30007 /* Utilities */,
AAA30008 /* Networking */,
AAA30009 /* Helpers */,
);
path = MacTorn;
sourceTree = "<group>";
@ -187,6 +198,8 @@
AAA10002 /* ContentView.swift */,
AAA10006 /* SettingsView.swift */,
AAA10007 /* StatusView.swift */,
AAA10023 /* TravelView.swift */,
AAA10024 /* CreditsView.swift */,
AAA10017 /* MoneyView.swift */,
AAA10018 /* AttacksView.swift */,
AAA10019 /* FactionView.swift */,
@ -204,6 +217,7 @@
AAA10013 /* ChainView.swift */,
AAA10014 /* StatusBadgesView.swift */,
AAA10015 /* EventsView.swift */,
AAA10026 /* FeedbackPromptView.swift */,
);
path = Components;
sourceTree = "<group>";
@ -227,6 +241,14 @@
path = Networking;
sourceTree = "<group>";
};
AAA30009 /* Helpers */ = {
isa = PBXGroup;
children = (
AAA10025 /* TransparencyEnvironment.swift */,
);
path = Helpers;
sourceTree = "<group>";
};
/* Unit Tests Groups */
BBB30000 /* MacTornTests */ = {
isa = PBXGroup;
@ -275,6 +297,7 @@
children = (
BBB10011 /* AppStateTests.swift */,
BBB10012 /* AppStateWatchlistTests.swift */,
BBB10013 /* AppStateFeedbackTests.swift */,
);
path = ViewModels;
sourceTree = "<group>";
@ -437,6 +460,10 @@
AAA00019 /* WatchlistView.swift in Sources */,
AAA00020 /* PropertiesView.swift in Sources */,
AAA00021 /* NetworkSession.swift in Sources */,
AAA00022 /* TravelView.swift in Sources */,
AAA00023 /* CreditsView.swift in Sources */,
AAA00024 /* TransparencyEnvironment.swift in Sources */,
AAA00025 /* FeedbackPromptView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -456,6 +483,7 @@
BBB00010 /* MoneyDataTests.swift in Sources */,
BBB00011 /* AppStateTests.swift in Sources */,
BBB00012 /* AppStateWatchlistTests.swift in Sources */,
BBB00013 /* AppStateFeedbackTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -596,6 +624,7 @@
MTL_FAST_MATH = YES;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
ARCHS = "arm64 x86_64";
};
name = Release;
};
@ -618,7 +647,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.4.7;
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
@ -645,7 +674,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.4.7;
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
@ -663,7 +692,7 @@
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.4.7;
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@ -681,7 +710,7 @@
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.4.7;
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@ -699,7 +728,7 @@
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.4.7;
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@ -716,7 +745,7 @@
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.4.7;
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;

View file

@ -0,0 +1,13 @@
import SwiftUI
// Environment key for reduce transparency setting
private struct ReduceTransparencyKey: EnvironmentKey {
static let defaultValue: Bool = false
}
extension EnvironmentValues {
var reduceTransparency: Bool {
get { self[ReduceTransparencyKey.self] }
set { self[ReduceTransparencyKey.self] = newValue }
}
}

View file

@ -17,8 +17,8 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.3</string>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>1.3</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
</dict>
</plist>

View file

@ -3,29 +3,65 @@ import SwiftUI
@main
struct MacTornApp: App {
@StateObject private var appState = AppState()
@AppStorage("appearanceMode") private var appearanceModeRaw: String = AppearanceMode.system.rawValue
@AppStorage("reduceTransparency") private var reduceTransparency: Bool = false
var body: some Scene {
MenuBarExtra {
ContentView()
.environmentObject(appState)
.environment(\.reduceTransparency, reduceTransparency)
.onAppear {
updateAppearance()
}
.onChange(of: appearanceModeRaw) { _ in
updateAppearance()
}
} label: {
Image(systemName: menuBarIcon)
.renderingMode(.template)
MenuBarLabel(appState: appState)
}
.menuBarExtraStyle(.window)
}
private func updateAppearance() {
let mode = AppearanceMode(rawValue: appearanceModeRaw) ?? .system
switch mode {
case .system:
NSApp.appearance = nil
case .light:
NSApp.appearance = NSAppearance(named: .aqua)
case .dark:
NSApp.appearance = NSAppearance(named: .darkAqua)
}
}
}
// MARK: - Menu Bar Label
struct MenuBarLabel: View {
@ObservedObject var appState: AppState
var body: some View {
// Show airplane + flag + countdown when traveling
Group {
if let travel = appState.data?.travel,
travel.isTraveling {
let destination = travel.destination ?? "?"
let flag = flagForDestination(destination)
let time = formatShortTime(appState.travelSecondsRemaining)
Text("✈️\(flag)\(time)")
} else {
Image(systemName: menuBarIcon)
}
}
.transaction { $0.animation = nil }
}
private var menuBarIcon: String {
// Error state
if appState.errorMsg != nil {
return "exclamationmark.triangle.fill"
}
// Traveling state
if let travel = appState.data?.travel, travel.isTraveling {
return "airplane"
}
// Abroad state
if let travel = appState.data?.travel, travel.isAbroad {
return "globe"
@ -41,4 +77,33 @@ struct MacTornApp: App {
// Default
return "bolt"
}
private func flagForDestination(_ destination: String) -> String {
switch destination.lowercased() {
case "mexico": return "🇲🇽"
case "cayman islands": return "🇰🇾"
case "canada": return "🇨🇦"
case "hawaii": return "🇺🇸"
case "united kingdom": return "🇬🇧"
case "argentina": return "🇦🇷"
case "switzerland": return "🇨🇭"
case "japan": return "🇯🇵"
case "china": return "🇨🇳"
case "uae": return "🇦🇪"
case "south africa": return "🇿🇦"
case "torn": return "🇺🇸"
default: return "🌍"
}
}
private func formatShortTime(_ seconds: Int) -> String {
if seconds <= 0 { return "0:00" }
let hours = seconds / 3600
let minutes = (seconds % 3600) / 60
let secs = seconds % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, secs)
}
return String(format: "%d:%02d", minutes, secs)
}
}

View file

@ -112,6 +112,114 @@ struct Travel: Codable, Equatable {
guard isTraveling, let ts = timestamp else { return nil }
return Date(timeIntervalSince1970: TimeInterval(ts))
}
/// Calculate remaining seconds based on fetch time (for live countdown)
func remainingSeconds(from fetchTime: Date) -> Int {
// Primary: Use timestamp directly if available (more accurate)
if let timestamp = timestamp, timestamp > 0 {
let now = Int(Date().timeIntervalSince1970)
return max(0, timestamp - now)
}
// Fallback: Use timeLeft with fetchTime offset (backward compatibility)
guard let timeLeft = timeLeft, timeLeft > 0 else { return 0 }
let elapsed = Int(Date().timeIntervalSince(fetchTime))
return max(0, timeLeft - elapsed)
}
/// Calculate flight progress (0.0 to 1.0) based on fetch time
func flightProgress(from fetchTime: Date) -> Double {
guard let departed = departed, let timestamp = timestamp else { return 0 }
let totalDuration = timestamp - departed
guard totalDuration > 0 else { return 0 }
let remaining = remainingSeconds(from: fetchTime)
let elapsed = totalDuration - remaining
return min(1.0, max(0.0, Double(elapsed) / Double(totalDuration)))
}
}
// MARK: - Travel Destinations
enum TornDestination: String, CaseIterable, Identifiable {
case mexico = "Mexico"
case caymanIslands = "Cayman Islands"
case canada = "Canada"
case hawaii = "Hawaii"
case unitedKingdom = "United Kingdom"
case argentina = "Argentina"
case switzerland = "Switzerland"
case japan = "Japan"
case china = "China"
case uae = "UAE"
case southAfrica = "South Africa"
var id: String { rawValue }
var flag: String {
switch self {
case .mexico: return "🇲🇽"
case .caymanIslands: return "🇰🇾"
case .canada: return "🇨🇦"
case .hawaii: return "🇺🇸"
case .unitedKingdom: return "🇬🇧"
case .argentina: return "🇦🇷"
case .switzerland: return "🇨🇭"
case .japan: return "🇯🇵"
case .china: return "🇨🇳"
case .uae: return "🇦🇪"
case .southAfrica: return "🇿🇦"
}
}
/// Approximate flight time in minutes
var flightTimeMinutes: Int {
switch self {
case .mexico: return 26
case .caymanIslands: return 35
case .canada: return 41
case .hawaii: return 134
case .unitedKingdom: return 159
case .argentina: return 167
case .switzerland: return 175
case .japan: return 225
case .china: return 242
case .uae: return 271
case .southAfrica: return 297
}
}
var flightTimeFormatted: String {
let hours = flightTimeMinutes / 60
let minutes = flightTimeMinutes % 60
if hours > 0 {
return "\(hours)h \(minutes)m"
}
return "\(minutes)m"
}
var travelAgencyURL: URL {
URL(string: "https://www.torn.com/travelagency.php")!
}
}
// MARK: - Travel Notification Settings
struct TravelNotificationSetting: Codable, Identifiable, Equatable {
let id: String
let secondsBefore: Int
var enabled: Bool
var displayName: String {
if secondsBefore >= 60 {
return "\(secondsBefore / 60) min before"
}
return "\(secondsBefore) sec before"
}
static let defaults: [TravelNotificationSetting] = [
TravelNotificationSetting(id: "travel_2min", secondsBefore: 120, enabled: false),
TravelNotificationSetting(id: "travel_1min", secondsBefore: 60, enabled: true),
TravelNotificationSetting(id: "travel_30sec", secondsBefore: 30, enabled: false),
TravelNotificationSetting(id: "travel_10sec", secondsBefore: 10, enabled: false)
]
}
// MARK: - Status (Hospital/Jail)
@ -258,8 +366,10 @@ struct AttackResult: Codable, Identifiable {
let code: String?
let timestampStarted: Int?
let timestampEnded: Int?
let opponentId: Int?
let opponentName: String?
let attackerId: Int?
let attackerName: String?
let defenderId: Int?
let defenderName: String?
let result: String?
let respect: Double?
@ -269,28 +379,68 @@ struct AttackResult: Codable, Identifiable {
case code
case timestampStarted = "timestamp_started"
case timestampEnded = "timestamp_ended"
case opponentId = "defender_id"
case opponentName = "defender_name"
case attackerId = "attacker_id"
case attackerName = "attacker_name"
case defenderId = "defender_id"
case defenderName = "defender_name"
case result, respect
}
var resultIcon: String {
func opponentName(forUserId userId: Int) -> String {
let name: String?
if attackerId == userId {
name = defenderName
} else {
name = attackerName
}
if let name = name, !name.isEmpty {
return name
}
return "Someone"
}
func opponentId(forUserId userId: Int) -> Int? {
if attackerId == userId {
return defenderId
} else {
return attackerId
}
}
func wasAttacker(userId: Int) -> Bool {
return attackerId == userId
}
func resultIcon(forUserId userId: Int) -> String {
let userWasAttacker = wasAttacker(userId: userId)
switch result {
case "Attacked": return "checkmark.circle.fill"
case "Mugged": return "dollarsign.circle.fill"
case "Hospitalized": return "cross.circle.fill"
case "Lost": return "xmark.circle.fill"
case "Attacked": return userWasAttacker ? "checkmark.circle.fill" : "xmark.circle.fill"
case "Mugged": return userWasAttacker ? "dollarsign.circle.fill" : "xmark.circle.fill"
case "Hospitalized": return userWasAttacker ? "cross.circle.fill" : "xmark.circle.fill"
case "Lost": return userWasAttacker ? "xmark.circle.fill" : "shield.checkered"
case "Stalemate": return "equal.circle.fill"
case "Escape": return userWasAttacker ? "figure.run" : "shield.checkered"
case "Assist": return "person.2.fill"
default: return "questionmark.circle"
}
}
var resultColor: Color {
func resultColor(forUserId userId: Int) -> Color {
let userWasAttacker = wasAttacker(userId: userId)
switch result {
case "Attacked", "Mugged", "Hospitalized": return .green
case "Lost": return .red
case "Stalemate": return .orange
default: return .gray
case "Attacked", "Mugged", "Hospitalized":
return userWasAttacker ? .green : .red
case "Lost":
return userWasAttacker ? .red : .green
case "Stalemate":
return .orange
case "Escape":
return userWasAttacker ? .orange : .green
case "Assist":
return .blue
default:
return .gray
}
}
@ -584,6 +734,14 @@ enum NotificationSound: String, CaseIterable {
}
}
// MARK: - App Feedback State
struct AppFeedbackState: Codable {
var firstLaunchDate: Date
var hasResponded: Bool
var dismissCount: Int
var lastDismissedDate: Date?
}
// MARK: - Keyboard Shortcuts
struct KeyboardShortcut: Identifiable, Codable, Equatable {
let id: String

View file

@ -13,12 +13,13 @@ enum NotificationType: String {
case nerve
case happy
case life
case travelApproaching
var url: URL {
switch self {
case .drugReady, .medicalReady, .boosterReady:
return URL(string: "https://www.torn.com/item.php")!
case .landed:
case .landed, .travelApproaching:
return URL(string: "https://www.torn.com/page.php?sid=ItemMarket")!
case .chainExpiring:
return URL(string: "https://www.torn.com/factions.php?step=your#/tab=wars")!
@ -74,6 +75,49 @@ class NotificationManager: NSObject, UNUserNotificationCenterDelegate {
}
}
/// Schedule a notification for a specific date
func scheduleNotification(title: String, body: String, type: NotificationType, at date: Date, identifier: String) {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
content.categoryIdentifier = type.rawValue
let triggerDate = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date)
let trigger = UNCalendarNotificationTrigger(dateMatching: triggerDate, repeats: false)
let request = UNNotificationRequest(
identifier: identifier,
content: content,
trigger: trigger
)
UNUserNotificationCenter.current().add(request) { error in
if let error = error {
print("Scheduled notification error: \(error)")
} else {
print("Scheduled notification '\(identifier)' for \(date)")
}
}
}
/// Cancel all travel-related notifications
func cancelTravelNotifications() {
let identifiers = [
"travel_2min_alert",
"travel_1min_alert",
"travel_30sec_alert",
"travel_10sec_alert"
]
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: identifiers)
print("Cancelled travel notifications")
}
/// Cancel a specific notification by identifier
func cancelNotification(identifier: String) {
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [identifier])
}
// MARK: - UNUserNotificationCenterDelegate
func userNotificationCenter(

View file

@ -5,11 +5,27 @@ import os.log
private let logger = Logger(subsystem: "com.mactorn", category: "AppState")
// MARK: - Appearance
enum AppearanceMode: String, CaseIterable {
case system = "System"
case light = "Light"
case dark = "Dark"
var colorScheme: ColorScheme? {
switch self {
case .system: return nil
case .light: return .light
case .dark: return .dark
}
}
}
@MainActor
class AppState: ObservableObject {
// MARK: - Persisted
@AppStorage("apiKey") var apiKey: String = ""
@AppStorage("refreshInterval") var refreshInterval: Int = 30
@AppStorage("appearanceMode") var appearanceMode: String = AppearanceMode.system.rawValue
// MARK: - Published State
@Published var data: TornResponse?
@ -17,6 +33,7 @@ class AppState: ObservableObject {
@Published var errorMsg: String?
@Published var isLoading: Bool = false
@Published var notificationRules: [NotificationRule] = []
@Published var travelNotificationSettings: [TravelNotificationSetting] = []
// MARK: - New Data Sources
@Published var moneyData: MoneyData?
@ -29,6 +46,18 @@ class AppState: ObservableObject {
// MARK: - Update State
@Published var updateAvailable: GitHubRelease?
// MARK: - Feedback State
@Published var feedbackState: AppFeedbackState?
@Published var showFeedbackPrompt: Bool = false
static let feedbackThresholds: [TimeInterval] = [3600, 7 * 86400, 30 * 86400]
// MARK: - Fetch Time (for live countdown calculations)
@Published var lastFetchTime: Date = Date()
// MARK: - Live Travel Countdown
@Published var travelSecondsRemaining: Int = 0
private var travelTimerCancellable: AnyCancellable?
// MARK: - Managers
let launchAtLogin = LaunchAtLoginManager()
let shortcutsManager = ShortcutsManager()
@ -50,7 +79,9 @@ class AppState: ObservableObject {
init(session: NetworkSession = URLSession.shared) {
self.session = session
loadNotificationRules()
loadTravelNotificationSettings()
loadWatchlist()
loadFeedbackState()
// Polling and permissions moved to onAppear in UI
}
@ -78,6 +109,102 @@ class AppState: ObservableObject {
}
}
// MARK: - Travel Notification Settings
func loadTravelNotificationSettings() {
if let data = UserDefaults.standard.data(forKey: "travelNotificationSettings"),
let settings = try? JSONDecoder().decode([TravelNotificationSetting].self, from: data) {
travelNotificationSettings = settings
} else {
travelNotificationSettings = TravelNotificationSetting.defaults
saveTravelNotificationSettings()
}
}
func saveTravelNotificationSettings() {
if let data = try? JSONEncoder().encode(travelNotificationSettings) {
UserDefaults.standard.set(data, forKey: "travelNotificationSettings")
}
}
func updateTravelNotificationSetting(_ setting: TravelNotificationSetting) {
if let index = travelNotificationSettings.firstIndex(where: { $0.id == setting.id }) {
travelNotificationSettings[index] = setting
saveTravelNotificationSettings()
// Reschedule notifications if currently traveling
if let travel = data?.travel, travel.isTraveling {
scheduleTravelNotifications(for: travel)
}
}
}
func scheduleTravelNotifications(for travel: Travel) {
// Cancel any existing travel notifications first
NotificationManager.shared.cancelTravelNotifications()
guard let arrivalDate = travel.arrivalDate else { return }
for setting in travelNotificationSettings where setting.enabled {
let notificationDate = arrivalDate.addingTimeInterval(-Double(setting.secondsBefore))
// Only schedule if the notification time is in the future
if notificationDate > Date() {
let identifier = "\(setting.id)_alert"
let timeText: String
if setting.secondsBefore >= 60 {
timeText = "\(setting.secondsBefore / 60) minute\(setting.secondsBefore >= 120 ? "s" : "")"
} else {
timeText = "\(setting.secondsBefore) seconds"
}
NotificationManager.shared.scheduleNotification(
title: "Landing Soon!",
body: "You will arrive in \(travel.destination ?? "your destination") in \(timeText)",
type: .travelApproaching,
at: notificationDate,
identifier: identifier
)
}
}
}
// MARK: - Live Travel Timer
func manageTravelTimer() {
if let travel = data?.travel, travel.isTraveling {
// Start or continue timer
updateTravelSecondsRemaining()
if travelTimerCancellable == nil {
startTravelTimer()
}
} else {
// Stop timer when not traveling
stopTravelTimer()
}
}
private func startTravelTimer() {
travelTimerCancellable?.cancel()
travelTimerCancellable = Timer.publish(every: 1.0, on: .main, in: .common)
.autoconnect()
.sink { [weak self] _ in
self?.updateTravelSecondsRemaining()
}
}
private func stopTravelTimer() {
travelTimerCancellable?.cancel()
travelTimerCancellable = nil
travelSecondsRemaining = 0
}
private func updateTravelSecondsRemaining() {
guard let travel = data?.travel, travel.isTraveling else {
travelSecondsRemaining = 0
return
}
travelSecondsRemaining = travel.remainingSeconds(from: lastFetchTime)
}
// MARK: - Watchlist
func loadWatchlist() {
if let data = UserDefaults.standard.data(forKey: "watchlist"),
@ -179,25 +306,11 @@ class AppState: ObservableObject {
let sortedListings = allListings.sorted { $0.price < $1.price }
logger.debug("Item \(itemId): found \(sortedListings.count) listings, lowest: \(sortedListings.first?.price ?? 0)")
await MainActor.run {
if let index = watchlistItems.firstIndex(where: { $0.id == itemId }) {
if let best = sortedListings.first {
watchlistItems[index].lowestPrice = best.price
watchlistItems[index].lowestPriceQuantity = best.amount
// Check for next distinct price or just next listing? usually user wants to know diff to next cheapest offer even if it's same price?
// Actually "second lowest price" usually implies the price of the *next available item*.
// But usually users want to know price steps.
// Let's stick to simple logic: price of the 2nd listing in sorted list.
watchlistItems[index].secondLowestPrice = sortedListings.count > 1 ? sortedListings[1].price : 0
watchlistItems[index].lastUpdated = Date()
watchlistItems[index].error = nil
} else {
watchlistItems[index].error = "No listings"
}
saveWatchlist()
}
if let best = sortedListings.first {
let secondPrice = sortedListings.count > 1 ? sortedListings[1].price : 0
await updateItemPrice(itemId: itemId, lowestPrice: best.price, lowestPriceQuantity: best.amount, secondLowestPrice: secondPrice)
} else {
await updateItemError(itemId: itemId, error: "No listings")
}
}
} catch {
@ -206,10 +319,26 @@ class AppState: ObservableObject {
}
}
@MainActor
private func updateItemPrice(itemId: Int, lowestPrice: Int, lowestPriceQuantity: Int, secondLowestPrice: Int) {
if let index = watchlistItems.firstIndex(where: { $0.id == itemId }) {
var item = watchlistItems[index]
item.lowestPrice = lowestPrice
item.lowestPriceQuantity = lowestPriceQuantity
item.secondLowestPrice = secondLowestPrice
item.lastUpdated = Date()
item.error = nil
watchlistItems[index] = item
saveWatchlist()
}
}
@MainActor
private func updateItemError(itemId: Int, error: String) {
if let index = watchlistItems.firstIndex(where: { $0.id == itemId }) {
watchlistItems[index].error = error
var item = watchlistItems[index]
item.error = error
watchlistItems[index] = item
saveWatchlist()
}
}
@ -379,8 +508,10 @@ class AppState: ObservableObject {
code: code,
timestampStarted: attackDict["timestamp_started"] as? Int,
timestampEnded: attackDict["timestamp_ended"] as? Int,
opponentId: attackDict["defender_id"] as? Int,
opponentName: attackDict["defender_name"] as? String,
attackerId: attackDict["attacker_id"] as? Int,
attackerName: attackDict["attacker_name"] as? String,
defenderId: attackDict["defender_id"] as? Int,
defenderName: attackDict["defender_name"] as? String,
result: attackDict["result"] as? String,
respect: attackDict["respect"] as? Double
)
@ -441,8 +572,15 @@ class AppState: ObservableObject {
if let p = result.4 { self.propertiesData = p }
self.lastUpdated = Date()
self.lastFetchTime = Date()
self.errorMsg = nil
// Manage travel timer after data is set
self.manageTravelTimer()
// Check if feedback prompt should be shown
self.checkFeedbackPrompt()
// Force UI update by triggering objectWillChange
self.objectWillChange.send()
logger.info("UI update triggered, lastUpdated: \(self.lastUpdated?.description ?? "nil")")
@ -512,9 +650,18 @@ class AppState: ObservableObject {
}
if let prevTravel = previousTravel, let currentTravel = newData.travel {
// Just landed
if prevTravel.isTraveling && !currentTravel.isTraveling {
NotificationManager.shared.send(title: "Landed! ✈️", body: "You have arrived in \(currentTravel.destination ?? "destination")", type: .landed)
NotificationManager.shared.send(title: "Landed!", body: "You have arrived in \(currentTravel.destination ?? "destination")", type: .landed)
NotificationManager.shared.cancelTravelNotifications()
}
// Just started traveling
if !prevTravel.isTraveling && currentTravel.isTraveling {
scheduleTravelNotifications(for: currentTravel)
}
} else if let currentTravel = newData.travel, currentTravel.isTraveling, previousTravel == nil {
// First data fetch while traveling
scheduleTravelNotifications(for: currentTravel)
}
if let chain = newData.chain, chain.isActive {
@ -524,7 +671,7 @@ class AppState: ObservableObject {
}
if let prevStatus = previousStatus, let currentStatus = newData.status {
if !prevStatus.isOkay && currentStatus.isOkay {
if (prevStatus.isInHospital || prevStatus.isInJail) && currentStatus.isOkay {
NotificationManager.shared.send(title: "Released! 🎉", body: "You are now free", type: .released)
}
}
@ -575,6 +722,73 @@ class AppState: ObservableObject {
}
}
}
// MARK: - Feedback Prompt
func loadFeedbackState() {
if let data = UserDefaults.standard.data(forKey: "appFeedbackState"),
let state = try? JSONDecoder().decode(AppFeedbackState.self, from: data) {
feedbackState = state
} else {
feedbackState = AppFeedbackState(
firstLaunchDate: Date(),
hasResponded: false,
dismissCount: 0,
lastDismissedDate: nil
)
saveFeedbackState()
}
}
func saveFeedbackState() {
guard let state = feedbackState,
let data = try? JSONEncoder().encode(state) else { return }
UserDefaults.standard.set(data, forKey: "appFeedbackState")
}
func checkFeedbackPrompt() {
guard let state = feedbackState else { return }
guard !state.hasResponded else { return }
guard state.dismissCount < Self.feedbackThresholds.count else { return }
// 5-minute cooldown after last dismissal
if let lastDismissed = state.lastDismissedDate,
Date().timeIntervalSince(lastDismissed) < 300 {
return
}
let elapsed = Date().timeIntervalSince(state.firstLaunchDate)
let requiredTime = Self.feedbackThresholds[state.dismissCount]
if elapsed >= requiredTime {
showFeedbackPrompt = true
}
}
func feedbackRespondedPositive() {
feedbackState?.hasResponded = true
showFeedbackPrompt = false
saveFeedbackState()
if let url = URL(string: "https://www.torn.com/forums.php#/p=threads&f=67&t=16532308") {
NSWorkspace.shared.open(url)
}
}
func feedbackRespondedNegative() {
feedbackState?.hasResponded = true
showFeedbackPrompt = false
saveFeedbackState()
if let url = URL(string: "mailto:pawel@orzech.lol?subject=MacTorn%20Feedback") {
NSWorkspace.shared.open(url)
}
}
func feedbackDismissed() {
feedbackState?.dismissCount += 1
feedbackState?.lastDismissedDate = Date()
showFeedbackPrompt = false
saveFeedbackState()
}
}
// MARK: - Errors

View file

@ -2,6 +2,7 @@ import SwiftUI
struct AttacksView: View {
@EnvironmentObject var appState: AppState
@Environment(\.reduceTransparency) private var reduceTransparency
var body: some View {
ScrollView {
@ -39,7 +40,7 @@ struct AttacksView: View {
}
}
.padding()
.background(Color.red.opacity(0.05))
.background(Color.red.opacity(reduceTransparency ? 0.25 : 0.05))
.cornerRadius(8)
// Recent Attacks
@ -51,23 +52,38 @@ struct AttacksView: View {
.font(.caption.bold())
}
if let attacks = appState.recentAttacks, !attacks.isEmpty {
if let attacks = appState.recentAttacks, !attacks.isEmpty,
let userId = appState.data?.playerId {
ForEach(attacks.prefix(5)) { attack in
HStack {
Image(systemName: attack.resultIcon)
.foregroundColor(attack.resultColor)
.frame(width: 16)
Button {
if let opponentId = attack.opponentId(forUserId: userId),
let url = URL(string: "https://www.torn.com/profiles.php?XID=\(opponentId)") {
NSWorkspace.shared.open(url)
}
} label: {
HStack(spacing: 6) {
Image(systemName: attack.resultIcon(forUserId: userId))
.foregroundColor(attack.resultColor(forUserId: userId))
.frame(width: 14)
Text(attack.opponentName ?? "Unknown")
.font(.caption)
.lineLimit(1)
Image(systemName: attack.wasAttacker(userId: userId) ? "arrow.right" : "arrow.left")
.font(.caption2)
.foregroundColor(attack.wasAttacker(userId: userId) ? .blue : .orange)
.frame(width: 12)
Spacer()
Text(attack.opponentName(forUserId: userId))
.font(.caption)
.lineLimit(1)
Text(attack.timeAgo)
.font(.caption2)
.foregroundColor(.secondary)
Spacer()
Text(attack.timeAgo)
.font(.caption2)
.foregroundColor(.secondary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
} else {
Text("No recent attacks")
@ -76,7 +92,7 @@ struct AttacksView: View {
}
}
.padding()
.background(Color.orange.opacity(0.05))
.background(Color.orange.opacity(reduceTransparency ? 0.25 : 0.05))
.cornerRadius(8)
// Actions
@ -119,6 +135,7 @@ struct AttacksView: View {
// MARK: - Stat Item
struct StatItem: View {
@Environment(\.reduceTransparency) private var reduceTransparency
let label: String
let value: String
let color: Color
@ -134,7 +151,7 @@ struct StatItem: View {
}
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
.background(color.opacity(0.1))
.background(color.opacity(reduceTransparency ? 0.4 : 0.1))
.cornerRadius(4)
}
}

View file

@ -1,27 +1,34 @@
import SwiftUI
struct ChainView: View {
@Environment(\.reduceTransparency) private var reduceTransparency
let chain: Chain
let fetchTime: Date
var body: some View {
if chain.isActive {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "link")
.foregroundColor(timeoutColor)
Text("Chain: \(chain.current ?? 0)/\(chain.maximum ?? 0)")
.font(.caption.bold())
TimelineView(.periodic(from: fetchTime, by: 1.0)) { context in
let remaining = max(0, (chain.timeout ?? 0) - Int(context.date.timeIntervalSince1970))
let color = timeoutColor(for: remaining)
Spacer()
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "link")
.foregroundColor(color)
Text("Chain: \(chain.current ?? 0)/\(chain.maximum ?? 0)")
.font(.caption.bold())
Text(formatTime(chain.timeoutRemaining))
.font(.caption.monospacedDigit())
.foregroundColor(timeoutColor)
Spacer()
Text(formatTime(remaining))
.font(.caption.monospacedDigit())
.foregroundColor(color)
}
}
.padding(8)
.background(color.opacity(reduceTransparency ? 0.4 : 0.1))
.cornerRadius(8)
}
.padding(8)
.background(timeoutColor.opacity(0.1))
.cornerRadius(8)
} else if chain.isOnCooldown {
HStack {
Image(systemName: "clock")
@ -33,10 +40,10 @@ struct ChainView: View {
}
}
private var timeoutColor: Color {
if chain.timeoutRemaining < 60 {
private func timeoutColor(for remaining: Int) -> Color {
if remaining < 60 {
return .red
} else if chain.timeoutRemaining < 180 {
} else if remaining < 180 {
return .orange
}
return .green

View file

@ -1,6 +1,7 @@
import SwiftUI
struct EventsView: View {
@Environment(\.reduceTransparency) private var reduceTransparency
let events: [TornEvent]
var body: some View {
@ -33,7 +34,7 @@ struct EventsView: View {
}
}
.padding(8)
.background(Color.blue.opacity(0.05))
.background(Color.blue.opacity(reduceTransparency ? 0.25 : 0.05))
.cornerRadius(8)
}

View file

@ -0,0 +1,74 @@
import SwiftUI
struct FeedbackPromptView: View {
@EnvironmentObject var appState: AppState
@Environment(\.reduceTransparency) private var reduceTransparency
var body: some View {
VStack(spacing: 16) {
Image(systemName: "heart.fill")
.font(.system(size: 32))
.foregroundStyle(
LinearGradient(
colors: [.pink, .red],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
Text("Enjoying MacTorn?")
.font(.headline)
Text("Your feedback helps make the app better for everyone.")
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
VStack(spacing: 8) {
Button {
appState.feedbackRespondedPositive()
} label: {
HStack(spacing: 6) {
Image(systemName: "hand.thumbsup.fill")
Text("Yes! Leave a review")
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.green.opacity(reduceTransparency ? 0.4 : 0.2))
.cornerRadius(8)
}
.buttonStyle(.plain)
Button {
appState.feedbackRespondedNegative()
} label: {
HStack(spacing: 6) {
Image(systemName: "envelope.fill")
Text("Not really — send feedback")
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.orange.opacity(reduceTransparency ? 0.4 : 0.2))
.cornerRadius(8)
}
.buttonStyle(.plain)
}
Button {
appState.feedbackDismissed()
} label: {
Text("Not now")
.font(.caption)
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
}
.padding(20)
.frame(width: 260)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(reduceTransparency ? Color(.windowBackgroundColor) : Color(.windowBackgroundColor).opacity(0.95))
.shadow(radius: 8)
)
}
}

View file

@ -1,6 +1,7 @@
import SwiftUI
struct ProgressBarView: View {
@Environment(\.reduceTransparency) private var reduceTransparency
let label: String
let current: Int
let maximum: Int
@ -40,10 +41,10 @@ struct ProgressBarView: View {
ZStack(alignment: .leading) {
// Background track
RoundedRectangle(cornerRadius: 4)
.fill(Color.gray.opacity(0.3))
.fill(Color.gray.opacity(reduceTransparency ? 0.5 : 0.3))
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(color.opacity(0.3), lineWidth: 1)
.stroke(color.opacity(reduceTransparency ? 0.5 : 0.3), lineWidth: 1)
)
// Filled progress
@ -51,13 +52,13 @@ struct ProgressBarView: View {
RoundedRectangle(cornerRadius: 4)
.fill(
LinearGradient(
colors: [color, color.opacity(0.7)],
colors: [color, color.opacity(reduceTransparency ? 0.9 : 0.7)],
startPoint: .leading,
endPoint: .trailing
)
)
.frame(width: max(4, geometry.size.width * progress))
.shadow(color: color.opacity(0.5), radius: 2, x: 0, y: 0)
.shadow(color: color.opacity(reduceTransparency ? 0.7 : 0.5), radius: 2, x: 0, y: 0)
}
}
}

View file

@ -1,6 +1,7 @@
import SwiftUI
struct StatusBadgesView: View {
@Environment(\.reduceTransparency) private var reduceTransparency
let status: Status
var body: some View {
@ -18,7 +19,7 @@ struct StatusBadgesView: View {
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.red.opacity(0.1))
.background(Color.red.opacity(reduceTransparency ? 0.4 : 0.1))
.cornerRadius(6)
}
@ -34,7 +35,7 @@ struct StatusBadgesView: View {
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.orange.opacity(0.1))
.background(Color.orange.opacity(reduceTransparency ? 0.4 : 0.1))
.cornerRadius(6)
}
}

View file

@ -2,6 +2,7 @@ import SwiftUI
enum AppTab: String, CaseIterable {
case status = "Status"
case travel = "Travel"
case money = "Money"
case attacks = "Attacks"
case faction = "Faction"
@ -10,6 +11,7 @@ enum AppTab: String, CaseIterable {
var icon: String {
switch self {
case .status: return "chart.bar.fill"
case .travel: return "airplane"
case .money: return "dollarsign.circle.fill"
case .attacks: return "bolt.shield.fill"
case .faction: return "person.3.fill"
@ -20,6 +22,7 @@ enum AppTab: String, CaseIterable {
struct ContentView: View {
@EnvironmentObject var appState: AppState
@Environment(\.reduceTransparency) private var reduceTransparency
@State private var showSettings = false
@State private var currentTab: AppTab = .status
@ -53,8 +56,8 @@ struct ContentView: View {
// Loading Overlay
if appState.isLoading && appState.lastUpdated == nil {
Color.black.opacity(0.4)
.background(.ultraThinMaterial)
(reduceTransparency ? Color(.windowBackgroundColor) : Color.black.opacity(0.4))
.background(reduceTransparency ? AnyShapeStyle(Color(.windowBackgroundColor)) : AnyShapeStyle(.ultraThinMaterial))
VStack(spacing: 12) {
ProgressView()
@ -64,6 +67,15 @@ struct ContentView: View {
.foregroundColor(.secondary)
}
}
// Feedback Prompt Overlay
if appState.showFeedbackPrompt {
(reduceTransparency ? Color(.windowBackgroundColor) : Color.black.opacity(0.3))
.background(reduceTransparency ? AnyShapeStyle(Color(.windowBackgroundColor)) : AnyShapeStyle(.ultraThinMaterial))
FeedbackPromptView()
.environmentObject(appState)
}
}
.frame(width: 320)
.onAppear {
@ -104,7 +116,7 @@ struct ContentView: View {
}
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(currentTab == tab ? Color.accentColor.opacity(0.2) : Color.clear)
.background(currentTab == tab ? Color.accentColor.opacity(reduceTransparency ? 0.3 : 0.2) : Color.clear)
.cornerRadius(6)
.contentShape(Rectangle()) // Make entire area clickable
}
@ -123,6 +135,9 @@ struct ContentView: View {
case .status:
StatusView()
.environmentObject(appState)
case .travel:
TravelView()
.environmentObject(appState)
case .money:
MoneyView()
.environmentObject(appState)

View file

@ -0,0 +1,274 @@
import SwiftUI
struct CreditsView: View {
@Environment(\.reduceTransparency) private var reduceTransparency
@Binding var showCredits: Bool
// MARK: - Developer
private let developer = TornContributor(name: "bombel", tornID: 2362436)
// MARK: - Special Thanks
private let specialThanks: [TornContributor] = [
TornContributor(name: "kaszmir", tornID: 3913934),
TornContributor(name: "dylanwishop", tornID: 3918903),
TornContributor(name: "constanziagatta", tornID: 3961012),
]
// MARK: - Faction
private let factionName = "The Masters"
private let factionID = 11559
// MARK: - Company
private let companyName = "Glory Holes Productions"
private let companyOwnerID = 2362436
var body: some View {
VStack(spacing: 16) {
// Header
VStack(spacing: 8) {
Image(systemName: "heart.fill")
.font(.system(size: 36))
.foregroundStyle(
LinearGradient(
colors: [.pink, .red],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
Text("Credits")
.font(.title2.bold())
}
Divider()
ScrollView {
VStack(spacing: 14) {
// Developer Section
developerSection
// Special Thanks Section
contributorSection(
title: "Special Thanks",
icon: "star.fill",
iconColor: .yellow,
contributors: specialThanks
)
// Faction Section
factionSection
// Company Section
companySection
}
.padding(.horizontal)
}
// Back Button
Button {
showCredits = false
} label: {
HStack(spacing: 4) {
Image(systemName: "chevron.left")
Text("Back to Settings")
}
.font(.caption)
}
.buttonStyle(.plain)
.foregroundColor(.accentColor)
}
.padding()
.frame(width: 320, height: 480)
}
// MARK: - Developer Section
private var developerSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
Image(systemName: "hammer.fill")
.foregroundColor(.orange)
Text("Created by")
.font(.subheadline.bold())
}
Button {
openTornProfile(developer.tornID!)
} label: {
HStack {
Text(developer.name)
.font(.caption.bold())
Spacer()
Image(systemName: "arrow.up.right.square")
.font(.caption2)
.foregroundColor(.secondary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundColor(.accentColor)
.padding(10)
.frame(maxWidth: .infinity)
.background(Color.orange.opacity(reduceTransparency ? 0.4 : 0.1))
.cornerRadius(8)
}
}
// MARK: - Faction Section
private var factionSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
Image(systemName: "shield.fill")
.foregroundColor(.blue)
Text("Faction")
.font(.subheadline.bold())
}
Button {
openFaction(factionID)
} label: {
HStack {
Text(factionName)
.font(.caption)
Spacer()
Image(systemName: "arrow.up.right.square")
.font(.caption2)
.foregroundColor(.secondary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundColor(.accentColor)
.padding(10)
.frame(maxWidth: .infinity)
.background(Color.secondary.opacity(reduceTransparency ? 0.4 : 0.1))
.cornerRadius(8)
}
}
// MARK: - Company Section
private var companySection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
Image(systemName: "building.2.fill")
.foregroundColor(.purple)
Text("Company")
.font(.subheadline.bold())
}
Button {
openCompany(companyOwnerID)
} label: {
HStack {
Text(companyName)
.font(.caption)
Spacer()
Image(systemName: "arrow.up.right.square")
.font(.caption2)
.foregroundColor(.secondary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundColor(.accentColor)
.padding(10)
.frame(maxWidth: .infinity)
.background(Color.secondary.opacity(reduceTransparency ? 0.4 : 0.1))
.cornerRadius(8)
}
}
// MARK: - Contributors Section
@ViewBuilder
private func contributorSection(
title: String,
icon: String,
iconColor: Color,
contributors: [TornContributor]
) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
Image(systemName: icon)
.foregroundColor(iconColor)
Text(title)
.font(.subheadline.bold())
}
VStack(spacing: 4) {
ForEach(contributors) { contributor in
contributorRow(contributor)
}
}
.padding(10)
.frame(maxWidth: .infinity)
.background(Color.secondary.opacity(reduceTransparency ? 0.4 : 0.1))
.cornerRadius(8)
}
}
// MARK: - Contributor Row
@ViewBuilder
private func contributorRow(_ contributor: TornContributor) -> some View {
if let tornID = contributor.tornID {
Button {
openTornProfile(tornID)
} label: {
HStack {
Text(contributor.name)
.font(.caption)
Spacer()
Image(systemName: "arrow.up.right.square")
.font(.caption2)
.foregroundColor(.secondary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundColor(.accentColor)
} else {
HStack {
Text(contributor.name)
.font(.caption)
.foregroundColor(.primary)
Spacer()
}
}
}
// MARK: - URL Helpers
private func openTornProfile(_ tornID: Int) {
let urlString = "https://www.torn.com/profiles.php?XID=\(tornID)"
if let url = URL(string: urlString) {
NSWorkspace.shared.open(url)
}
}
private func openFaction(_ factionID: Int) {
let urlString = "https://www.torn.com/factions.php?step=profile&ID=\(factionID)"
if let url = URL(string: urlString) {
NSWorkspace.shared.open(url)
}
}
private func openCompany(_ ownerID: Int) {
let urlString = "https://www.torn.com/joblist.php#/p=corpinfo&userID=\(ownerID)"
if let url = URL(string: urlString) {
NSWorkspace.shared.open(url)
}
}
}
// MARK: - Contributor Model
struct TornContributor: Identifiable {
let id = UUID()
let name: String
let tornID: Int?
init(name: String, tornID: Int?) {
self.name = name
self.tornID = tornID
}
}
#Preview {
CreditsView(showCredits: .constant(true))
}

View file

@ -2,6 +2,7 @@ import SwiftUI
struct FactionView: View {
@EnvironmentObject var appState: AppState
@Environment(\.reduceTransparency) private var reduceTransparency
var body: some View {
ScrollView {
@ -33,7 +34,7 @@ struct FactionView: View {
.foregroundColor(chainColor(faction.chain))
}
.padding(8)
.background(chainColor(faction.chain).opacity(0.1))
.background(chainColor(faction.chain).opacity(reduceTransparency ? 0.4 : 0.1))
.cornerRadius(6)
}
@ -58,7 +59,7 @@ struct FactionView: View {
}
}
.padding()
.background(Color.blue.opacity(0.05))
.background(Color.blue.opacity(reduceTransparency ? 0.25 : 0.05))
.cornerRadius(8)
// Armory Quick Actions
@ -85,7 +86,7 @@ struct FactionView: View {
}
}
.padding()
.background(Color.purple.opacity(0.05))
.background(Color.purple.opacity(reduceTransparency ? 0.25 : 0.05))
.cornerRadius(8)
// Actions
@ -138,6 +139,7 @@ struct FactionView: View {
// MARK: - Armory Button
struct ArmoryButton: View {
@Environment(\.reduceTransparency) private var reduceTransparency
let title: String
let icon: String
let color: Color
@ -153,7 +155,7 @@ struct ArmoryButton: View {
}
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(color.opacity(0.15))
.background(color.opacity(reduceTransparency ? 0.4 : 0.15))
.foregroundColor(color)
.cornerRadius(6)
}

View file

@ -2,6 +2,7 @@ import SwiftUI
struct MoneyView: View {
@EnvironmentObject var appState: AppState
@Environment(\.reduceTransparency) private var reduceTransparency
var body: some View {
ScrollView {
@ -72,7 +73,7 @@ struct MoneyView: View {
}
}
.padding()
.background(Color.green.opacity(0.05))
.background(Color.green.opacity(reduceTransparency ? 0.25 : 0.05))
.cornerRadius(8)
// Actions
@ -112,6 +113,7 @@ struct MoneyView: View {
// MARK: - Action Button Component
struct ActionButton: View {
@Environment(\.reduceTransparency) private var reduceTransparency
let title: String
let icon: String
let color: Color
@ -127,7 +129,7 @@ struct ActionButton: View {
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(color.opacity(0.1))
.background(color.opacity(reduceTransparency ? 0.4 : 0.1))
.foregroundColor(color)
.cornerRadius(8)
}

View file

@ -2,6 +2,7 @@ import SwiftUI
struct PropertiesView: View {
@EnvironmentObject var appState: AppState
@Environment(\.reduceTransparency) private var reduceTransparency
var body: some View {
ScrollView {
@ -58,6 +59,7 @@ struct PropertiesView: View {
// MARK: - Property Card
struct PropertyCard: View {
@Environment(\.reduceTransparency) private var reduceTransparency
let property: PropertyInfo
var body: some View {
@ -72,7 +74,7 @@ struct PropertyCard: View {
.foregroundColor(.orange)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.orange.opacity(0.2))
.background(Color.orange.opacity(reduceTransparency ? 0.5 : 0.2))
.cornerRadius(4)
}
}
@ -112,7 +114,7 @@ struct PropertyCard: View {
}
}
.padding()
.background(Color.brown.opacity(0.05))
.background(Color.brown.opacity(reduceTransparency ? 0.25 : 0.05))
.cornerRadius(8)
}

View file

@ -2,12 +2,23 @@ import SwiftUI
struct SettingsView: View {
@EnvironmentObject var appState: AppState
@AppStorage("appearanceMode") private var appearanceMode: String = AppearanceMode.system.rawValue
@AppStorage("reduceTransparency") private var reduceTransparency: Bool = false
@State private var inputKey: String = ""
@State private var showCredits: Bool = false
// Developer ID for tip feature (bombel)
private let developerID = 2362436
var body: some View {
if showCredits {
CreditsView(showCredits: $showCredits)
} else {
settingsContent
}
}
private var settingsContent: some View {
VStack(spacing: 20) {
// Header
Image(systemName: "bolt.circle.fill")
@ -79,6 +90,30 @@ struct SettingsView: View {
))
.toggleStyle(.switch)
}
// Appearance Mode
HStack {
Image(systemName: "moon.circle")
.foregroundColor(.secondary)
.frame(width: 20)
Picker("Appearance", selection: $appearanceMode) {
ForEach(AppearanceMode.allCases, id: \.self) { mode in
Text(mode.rawValue).tag(mode.rawValue)
}
}
.pickerStyle(.segmented)
.labelsHidden()
}
// Reduce Transparency (Accessibility)
HStack {
Image(systemName: "eye")
.foregroundColor(.secondary)
.frame(width: 20)
Toggle("Reduce Transparency", isOn: $reduceTransparency)
.toggleStyle(.switch)
}
}
.padding(.horizontal)
@ -107,14 +142,14 @@ struct SettingsView: View {
.font(.caption)
.padding(.vertical, 6)
.padding(.horizontal, 12)
.background(Color.purple.opacity(0.15))
.background(Color.purple.opacity(reduceTransparency ? 0.4 : 0.15))
.cornerRadius(6)
}
.buttonStyle(.plain)
}
.padding(.vertical, 8)
.padding(.horizontal)
.background(Color.purple.opacity(0.05))
.background(Color.purple.opacity(reduceTransparency ? 0.25 : 0.05))
.cornerRadius(8)
// Update Section
@ -137,19 +172,35 @@ struct SettingsView: View {
}
.padding(10)
.frame(maxWidth: .infinity)
.background(Color.green.opacity(0.1))
.background(Color.green.opacity(reduceTransparency ? 0.4 : 0.1))
.cornerRadius(8)
}
// GitHub & Version
VStack(spacing: 4) {
HStack(spacing: 4) {
Image(systemName: "chevron.left.forwardslash.chevron.right")
.font(.caption2)
.foregroundColor(.gray)
Link("View on GitHub",
destination: URL(string: "https://github.com/pawelorzech/MacTorn")!)
.font(.caption)
HStack(spacing: 12) {
HStack(spacing: 4) {
Image(systemName: "chevron.left.forwardslash.chevron.right")
.font(.caption2)
.foregroundColor(.gray)
Link("View on GitHub",
destination: URL(string: "https://github.com/pawelorzech/MacTorn")!)
.font(.caption)
}
Button {
showCredits = true
} label: {
HStack(spacing: 4) {
Image(systemName: "heart.fill")
.font(.caption2)
.foregroundColor(.pink)
Text("Credits")
.font(.caption)
}
}
.buttonStyle(.plain)
.foregroundColor(.accentColor)
}
if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String {

View file

@ -2,6 +2,7 @@ import SwiftUI
struct StatusView: View {
@EnvironmentObject var appState: AppState
@Environment(\.reduceTransparency) private var reduceTransparency
var body: some View {
ScrollView {
@ -20,8 +21,8 @@ struct StatusView: View {
}
// Chain status
if let chain = appState.data?.chain {
ChainView(chain: chain)
if let chain = appState.data?.chain, let fetchTime = appState.lastUpdated {
ChainView(chain: chain, fetchTime: fetchTime)
}
// Travel status
@ -109,7 +110,7 @@ struct StatusView: View {
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.blue.opacity(0.1))
.background(Color.blue.opacity(reduceTransparency ? 0.2 : 0.1))
.cornerRadius(6)
}
.buttonStyle(.plain)
@ -142,7 +143,7 @@ struct StatusView: View {
Text("Arriving in:")
.font(.caption2)
.foregroundColor(.secondary)
Text(formatTime(travel.timeLeft ?? 0))
Text(formatTime(appState.travelSecondsRemaining))
.font(.caption.monospacedDigit())
.foregroundColor(.blue)
}
@ -150,8 +151,9 @@ struct StatusView: View {
}
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.blue.opacity(0.1))
.background(Color.blue.opacity(reduceTransparency ? 0.2 : 0.1))
.cornerRadius(8)
.transaction { $0.animation = nil }
}
// MARK: - Bars
@ -239,7 +241,7 @@ struct StatusView: View {
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
.padding(.horizontal, 6)
.background(Color.accentColor.opacity(0.1))
.background(Color.accentColor.opacity(reduceTransparency ? 0.2 : 0.1))
.cornerRadius(4)
}
.buttonStyle(.plain)
@ -269,7 +271,7 @@ struct CooldownItem: View {
var body: some View {
VStack(spacing: 2) {
Image(systemName: icon)
Text(label)
.font(.caption)
.foregroundColor(seconds > 0 ? .orange : .green)
@ -306,7 +308,7 @@ struct LiveCooldownItem: View {
let remaining = max(0, originalSeconds - elapsed)
VStack(spacing: 2) {
Image(systemName: icon)
Text(label)
.font(.caption)
.foregroundColor(remaining > 0 ? .orange : .green)

View file

@ -0,0 +1,353 @@
import SwiftUI
import AppKit
// MARK: - Flying Status View (separate for proper live updates)
struct FlyingStatusView: View {
@EnvironmentObject var appState: AppState
@Environment(\.reduceTransparency) private var reduceTransparency
let destination: String
let timestamp: Int
let departed: Int
private var secondsRemaining: Int {
appState.travelSecondsRemaining
}
private var progress: Double {
let totalDuration = timestamp - departed
guard totalDuration > 0 else { return 0 }
return min(1.0, max(0.0, Double(totalDuration - secondsRemaining) / Double(totalDuration)))
}
private func formatTime(_ seconds: Int) -> String {
if seconds <= 0 { return "Arrived!" }
let hours = seconds / 3600
let minutes = (seconds % 3600) / 60
let secs = seconds % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, secs)
}
return String(format: "%d:%02d", minutes, secs)
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "airplane")
.font(.title2)
.foregroundColor(.blue)
VStack(alignment: .leading, spacing: 2) {
Text("Flying to \(destination)")
.font(.headline)
Text("In transit...")
.font(.caption)
.foregroundColor(.secondary)
}
}
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Arriving in:")
.font(.subheadline)
.foregroundColor(.secondary)
Spacer()
Text(formatTime(secondsRemaining))
.font(.title2.monospacedDigit())
.fontWeight(.semibold)
.foregroundColor(.blue)
}
// Progress bar
GeometryReader { geometry in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.gray.opacity(reduceTransparency ? 0.5 : 0.2))
.frame(height: 8)
RoundedRectangle(cornerRadius: 4)
.fill(Color.blue)
.frame(width: geometry.size.width * progress, height: 8)
}
}
.frame(height: 8)
}
}
.padding()
.background(Color.blue.opacity(reduceTransparency ? 0.2 : 0.1))
.cornerRadius(12)
.transaction { $0.animation = nil }
}
}
struct TravelView: View {
@EnvironmentObject var appState: AppState
@Environment(\.reduceTransparency) private var reduceTransparency
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
// Travel Status Section
travelStatusSection
Divider()
// Quick Travel Section (only when not traveling)
if let travel = appState.data?.travel, !travel.isTraveling {
quickTravelSection(isAbroad: travel.isAbroad, currentLocation: travel.destination)
Divider()
}
// Pre-Arrival Alerts Section
preArrivalAlertsSection
// Quick Actions
quickActionsSection
}
.padding()
}
.fixedSize(horizontal: false, vertical: true)
}
// MARK: - Travel Status Section
@ViewBuilder
private var travelStatusSection: some View {
if let travel = appState.data?.travel {
if travel.isTraveling {
// Flying state with live countdown - FlyingStatusView observes appState directly
FlyingStatusView(
destination: travel.destination ?? "Unknown",
timestamp: travel.timestamp ?? 0,
departed: travel.departed ?? 0
)
} else if travel.isAbroad {
// Abroad state
abroadStatusView(travel)
} else {
// In Torn City
inTornStatusView
}
} else {
// No travel data
inTornStatusView
}
}
private func abroadStatusView(_ travel: Travel) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "globe")
.font(.title2)
.foregroundColor(.orange)
VStack(alignment: .leading, spacing: 2) {
Text("In \(travel.destination ?? "Unknown")")
.font(.headline)
Text("Currently abroad")
.font(.caption)
.foregroundColor(.secondary)
}
}
Button {
if let url = URL(string: "https://www.torn.com/travelagency.php") {
NSWorkspace.shared.open(url)
}
} label: {
HStack {
Image(systemName: "airplane.departure")
Text("Return to Torn")
}
.font(.subheadline.bold())
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.orange)
.foregroundColor(.white)
.cornerRadius(8)
}
.buttonStyle(.plain)
}
.padding()
.background(Color.orange.opacity(reduceTransparency ? 0.2 : 0.1))
.cornerRadius(12)
}
private var inTornStatusView: some View {
HStack {
Image(systemName: "house.fill")
.font(.title2)
.foregroundColor(.green)
VStack(alignment: .leading, spacing: 2) {
Text("In Torn City")
.font(.headline)
Text("Ready to travel")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
.padding()
.background(Color.green.opacity(reduceTransparency ? 0.2 : 0.1))
.cornerRadius(12)
}
// MARK: - Quick Travel Section
private func quickTravelSection(isAbroad: Bool, currentLocation: String?) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "map.fill")
.foregroundColor(.secondary)
Text("Quick Travel")
.font(.subheadline.bold())
.foregroundColor(.secondary)
}
if isAbroad {
// Show only return button when abroad
Button {
if let url = URL(string: "https://www.torn.com/travelagency.php") {
NSWorkspace.shared.open(url)
}
} label: {
HStack {
Text("Torn")
Text("Return Home")
.font(.caption)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(Color.accentColor.opacity(reduceTransparency ? 0.2 : 0.1))
.cornerRadius(8)
}
.buttonStyle(.plain)
} else {
// Show all destinations grid
LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible())
], spacing: 8) {
ForEach(TornDestination.allCases) { destination in
destinationButton(destination)
}
}
}
}
}
private func destinationButton(_ destination: TornDestination) -> some View {
Button {
NSWorkspace.shared.open(destination.travelAgencyURL)
} label: {
VStack(spacing: 4) {
HStack(spacing: 4) {
Text(destination.flag)
.font(.title3)
Text(destination.rawValue)
.font(.caption)
.lineLimit(1)
}
Text("~\(destination.flightTimeFormatted)")
.font(.caption2)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.accentColor.opacity(reduceTransparency ? 0.2 : 0.1))
.cornerRadius(8)
}
.buttonStyle(.plain)
}
// MARK: - Pre-Arrival Alerts Section
private var preArrivalAlertsSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "bell.fill")
.foregroundColor(.secondary)
Text("Pre-Arrival Alerts")
.font(.subheadline.bold())
.foregroundColor(.secondary)
}
VStack(spacing: 8) {
ForEach(appState.travelNotificationSettings) { setting in
Toggle(isOn: Binding(
get: { setting.enabled },
set: { newValue in
var updated = setting
updated.enabled = newValue
appState.updateTravelNotificationSetting(updated)
}
)) {
Text(setting.displayName)
.font(.subheadline)
}
.toggleStyle(.switch)
.controlSize(.small)
}
}
.padding()
.background(Color.secondary.opacity(reduceTransparency ? 0.2 : 0.1))
.cornerRadius(8)
}
}
// MARK: - Quick Actions Section
private var quickActionsSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "link")
.foregroundColor(.secondary)
Text("Quick Actions")
.font(.subheadline.bold())
.foregroundColor(.secondary)
}
HStack(spacing: 8) {
Button {
if let url = URL(string: "https://www.torn.com/travelagency.php") {
NSWorkspace.shared.open(url)
}
} label: {
HStack {
Image(systemName: "airplane.departure")
Text("Travel Agency")
}
.font(.caption)
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.accentColor.opacity(reduceTransparency ? 0.2 : 0.1))
.cornerRadius(6)
}
.buttonStyle(.plain)
Button {
if let url = URL(string: "https://www.torn.com/page.php?sid=ItemMarket") {
NSWorkspace.shared.open(url)
}
} label: {
HStack {
Image(systemName: "storefront")
Text("Abroad")
}
.font(.caption)
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.accentColor.opacity(reduceTransparency ? 0.2 : 0.1))
.cornerRadius(6)
}
.buttonStyle(.plain)
}
}
}
// MARK: - Helpers
private func formatTime(_ seconds: Int) -> String {
if seconds <= 0 { return "Arrived!" }
let hours = seconds / 3600
let minutes = (seconds % 3600) / 60
let secs = seconds % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, secs)
}
return String(format: "%d:%02d", minutes, secs)
}
}

View file

@ -2,6 +2,7 @@ import SwiftUI
struct WatchlistView: View {
@EnvironmentObject var appState: AppState
@Environment(\.reduceTransparency) private var reduceTransparency
@State private var showAddItem = false
var body: some View {
@ -56,7 +57,7 @@ struct WatchlistView: View {
.lineLimit(1)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(Color.green.opacity(0.1))
.background(Color.green.opacity(reduceTransparency ? 0.4 : 0.1))
.cornerRadius(4)
}
.buttonStyle(.plain)
@ -64,7 +65,7 @@ struct WatchlistView: View {
}
}
.padding(8)
.background(Color.gray.opacity(0.1))
.background(Color.gray.opacity(reduceTransparency ? 0.4 : 0.1))
.cornerRadius(6)
}
@ -132,6 +133,7 @@ struct WatchlistView: View {
// MARK: - Watchlist Price Row
struct WatchlistPriceRow: View {
@Environment(\.reduceTransparency) private var reduceTransparency
let item: WatchlistItem
let onOpen: () -> Void
let onRemove: () -> Void
@ -200,7 +202,7 @@ struct WatchlistPriceRow: View {
.buttonStyle(.plain)
}
.padding(8)
.background(Color.gray.opacity(0.1))
.background(Color.gray.opacity(reduceTransparency ? 0.4 : 0.1))
.cornerRadius(6)
}

View file

@ -132,4 +132,108 @@ final class TravelTests: XCTestCase {
let travel = try decode(Travel.self, from: json)
XCTAssertFalse(travel.isTraveling)
}
// MARK: - remainingSeconds Tests
func testRemainingSeconds_usesTimestampDirectly() throws {
// Set arrival time 60 seconds in the future
let futureTimestamp = Int(Date().timeIntervalSince1970) + 60
let json: [String: Any] = [
"destination": "Japan",
"timestamp": futureTimestamp,
"departed": futureTimestamp - 1000,
"time_left": 60
]
let travel = try decode(Travel.self, from: json)
// Even with a stale fetchTime, should use timestamp directly
let staleFetchTime = Date().addingTimeInterval(-300) // 5 minutes ago
let remaining = travel.remainingSeconds(from: staleFetchTime)
// Should be approximately 60 seconds (allow 1-2 seconds tolerance for test execution)
XCTAssertGreaterThanOrEqual(remaining, 58)
XCTAssertLessThanOrEqual(remaining, 62)
}
func testRemainingSeconds_fallsBackToTimeLeftWhenTimestampNil() throws {
let json: [String: Any] = [
"destination": "Japan",
"time_left": 120
]
let travel = try decode(Travel.self, from: json)
let fetchTime = Date()
let remaining = travel.remainingSeconds(from: fetchTime)
// Should use timeLeft since timestamp is nil
XCTAssertGreaterThanOrEqual(remaining, 118)
XCTAssertLessThanOrEqual(remaining, 120)
}
func testRemainingSeconds_fallsBackToTimeLeftWhenTimestampZero() throws {
let json: [String: Any] = [
"destination": "Japan",
"timestamp": 0,
"time_left": 90
]
let travel = try decode(Travel.self, from: json)
let fetchTime = Date()
let remaining = travel.remainingSeconds(from: fetchTime)
// Should use timeLeft since timestamp is 0
XCTAssertGreaterThanOrEqual(remaining, 88)
XCTAssertLessThanOrEqual(remaining, 90)
}
func testRemainingSeconds_returnsZeroWhenArrivalPassed() throws {
// Set arrival time in the past
let pastTimestamp = Int(Date().timeIntervalSince1970) - 60
let json: [String: Any] = [
"destination": "Japan",
"timestamp": pastTimestamp,
"departed": pastTimestamp - 1000,
"time_left": 0
]
let travel = try decode(Travel.self, from: json)
let remaining = travel.remainingSeconds(from: Date())
XCTAssertEqual(remaining, 0)
}
func testRemainingSeconds_consistentRegardlessOfFetchTime() throws {
// Set arrival time 120 seconds in the future
let futureTimestamp = Int(Date().timeIntervalSince1970) + 120
let json: [String: Any] = [
"destination": "Japan",
"timestamp": futureTimestamp,
"departed": futureTimestamp - 1000,
"time_left": 120
]
let travel = try decode(Travel.self, from: json)
// Test with different fetchTimes - result should be the same
let recentFetchTime = Date()
let staleFetchTime = Date().addingTimeInterval(-60)
let veryOldFetchTime = Date().addingTimeInterval(-600)
let remaining1 = travel.remainingSeconds(from: recentFetchTime)
let remaining2 = travel.remainingSeconds(from: staleFetchTime)
let remaining3 = travel.remainingSeconds(from: veryOldFetchTime)
// All should return approximately the same value (within 1 second tolerance)
XCTAssertEqual(remaining1, remaining2, accuracy: 1)
XCTAssertEqual(remaining2, remaining3, accuracy: 1)
}
func testRemainingSeconds_zeroWhenNotTraveling() throws {
let json: [String: Any] = [
"destination": "Torn",
"time_left": 0
]
let travel = try decode(Travel.self, from: json)
let remaining = travel.remainingSeconds(from: Date())
XCTAssertEqual(remaining, 0)
}
}

View file

@ -0,0 +1,186 @@
import XCTest
@testable import MacTorn
@MainActor
final class AppStateFeedbackTests: XCTestCase {
var mockSession: MockNetworkSession!
var appState: AppState!
override func setUp() async throws {
try await super.setUp()
mockSession = MockNetworkSession()
UserDefaults.standard.removeObject(forKey: "appFeedbackState")
appState = AppState(session: mockSession)
}
override func tearDown() async throws {
appState.stopPolling()
appState = nil
mockSession = nil
UserDefaults.standard.removeObject(forKey: "appFeedbackState")
try await super.tearDown()
}
// MARK: - First Launch
func testFirstLaunch_createsFeedbackState() {
XCTAssertNotNil(appState.feedbackState)
XCTAssertFalse(appState.feedbackState!.hasResponded)
XCTAssertEqual(appState.feedbackState!.dismissCount, 0)
XCTAssertNil(appState.feedbackState!.lastDismissedDate)
}
// MARK: - Threshold Logic
func testBeforeOneHour_promptDoesNotShow() {
// firstLaunchDate is just now, so less than 1 hour has elapsed
appState.checkFeedbackPrompt()
XCTAssertFalse(appState.showFeedbackPrompt)
}
func testAfterOneHour_promptShows() {
appState.feedbackState?.firstLaunchDate = Date().addingTimeInterval(-3601)
appState.saveFeedbackState()
appState.checkFeedbackPrompt()
XCTAssertTrue(appState.showFeedbackPrompt)
}
func testAfterDismissOnce_needsOneWeek() {
// Set first launch to 2 hours ago, dismiss once
appState.feedbackState?.firstLaunchDate = Date().addingTimeInterval(-2 * 3600)
appState.feedbackState?.dismissCount = 1
appState.feedbackState?.lastDismissedDate = Date().addingTimeInterval(-600) // 10 min ago (past cooldown)
appState.saveFeedbackState()
appState.checkFeedbackPrompt()
// 2 hours < 1 week, so should not show
XCTAssertFalse(appState.showFeedbackPrompt)
}
func testAfterDismissOnce_afterOneWeek_promptShows() {
appState.feedbackState?.firstLaunchDate = Date().addingTimeInterval(-8 * 86400) // 8 days ago
appState.feedbackState?.dismissCount = 1
appState.feedbackState?.lastDismissedDate = Date().addingTimeInterval(-600)
appState.saveFeedbackState()
appState.checkFeedbackPrompt()
XCTAssertTrue(appState.showFeedbackPrompt)
}
func testAfterDismissTwice_needsOneMonth() {
appState.feedbackState?.firstLaunchDate = Date().addingTimeInterval(-14 * 86400) // 14 days ago
appState.feedbackState?.dismissCount = 2
appState.feedbackState?.lastDismissedDate = Date().addingTimeInterval(-600)
appState.saveFeedbackState()
appState.checkFeedbackPrompt()
// 14 days < 30 days, so should not show
XCTAssertFalse(appState.showFeedbackPrompt)
}
func testAfterDismissTwice_afterOneMonth_promptShows() {
appState.feedbackState?.firstLaunchDate = Date().addingTimeInterval(-31 * 86400) // 31 days ago
appState.feedbackState?.dismissCount = 2
appState.feedbackState?.lastDismissedDate = Date().addingTimeInterval(-600)
appState.saveFeedbackState()
appState.checkFeedbackPrompt()
XCTAssertTrue(appState.showFeedbackPrompt)
}
func testAfterDismissThreeTimes_neverShows() {
appState.feedbackState?.firstLaunchDate = Date().addingTimeInterval(-365 * 86400) // 1 year ago
appState.feedbackState?.dismissCount = 3
appState.saveFeedbackState()
appState.checkFeedbackPrompt()
XCTAssertFalse(appState.showFeedbackPrompt)
}
// MARK: - Responses
func testPositiveResponse_setsHasRespondedAndHidesPrompt() {
appState.showFeedbackPrompt = true
appState.feedbackRespondedPositive()
XCTAssertTrue(appState.feedbackState!.hasResponded)
XCTAssertFalse(appState.showFeedbackPrompt)
}
func testNegativeResponse_setsHasRespondedAndHidesPrompt() {
appState.showFeedbackPrompt = true
appState.feedbackRespondedNegative()
XCTAssertTrue(appState.feedbackState!.hasResponded)
XCTAssertFalse(appState.showFeedbackPrompt)
}
// MARK: - Dismiss
func testDismiss_incrementsDismissCount() {
XCTAssertEqual(appState.feedbackState!.dismissCount, 0)
appState.feedbackDismissed()
XCTAssertEqual(appState.feedbackState!.dismissCount, 1)
XCTAssertFalse(appState.showFeedbackPrompt)
XCTAssertNotNil(appState.feedbackState!.lastDismissedDate)
}
// MARK: - After Responded
func testAfterResponded_neverShowsAgain() {
appState.feedbackState?.hasResponded = true
appState.feedbackState?.firstLaunchDate = Date().addingTimeInterval(-365 * 86400)
appState.saveFeedbackState()
appState.checkFeedbackPrompt()
XCTAssertFalse(appState.showFeedbackPrompt)
}
// MARK: - Persistence
func testStatePersistsAcrossAppStateInstances() {
// Set a specific first launch date and dismiss once
appState.feedbackState?.firstLaunchDate = Date().addingTimeInterval(-86400)
appState.feedbackState?.dismissCount = 1
appState.saveFeedbackState()
// Create a new AppState instance (simulates app restart)
let newAppState = AppState(session: mockSession)
XCTAssertNotNil(newAppState.feedbackState)
XCTAssertEqual(newAppState.feedbackState!.dismissCount, 1)
// firstLaunchDate should be approximately 1 day ago
let elapsed = Date().timeIntervalSince(newAppState.feedbackState!.firstLaunchDate)
XCTAssertTrue(elapsed > 86300 && elapsed < 86500)
newAppState.stopPolling()
}
// MARK: - Cooldown
func testFiveMinuteCooldown_preventsImmediateReshow() {
// Set eligible threshold (1 hour elapsed, dismissCount 0)
appState.feedbackState?.firstLaunchDate = Date().addingTimeInterval(-3601)
// But dismissed just 2 minutes ago
appState.feedbackState?.lastDismissedDate = Date().addingTimeInterval(-120)
// dismissCount is still 0 since we're simulating the state manually
appState.saveFeedbackState()
appState.checkFeedbackPrompt()
XCTAssertFalse(appState.showFeedbackPrompt)
}
func testAfterCooldown_promptCanShow() {
appState.feedbackState?.firstLaunchDate = Date().addingTimeInterval(-3601)
// Dismissed 6 minutes ago (past the 5-minute cooldown)
appState.feedbackState?.lastDismissedDate = Date().addingTimeInterval(-360)
appState.saveFeedbackState()
appState.checkFeedbackPrompt()
XCTAssertTrue(appState.showFeedbackPrompt)
}
}

View file

@ -57,13 +57,15 @@ build:
CODE_SIGN_IDENTITY="-" \
CODE_SIGNING_REQUIRED=NO
# Build Release
# Build Release (Universal Binary for Intel + Apple Silicon)
release:
xcodebuild build \
-project MacTorn/MacTorn.xcodeproj \
-scheme MacTorn \
-configuration Release \
-destination 'platform=macOS' \
-destination 'generic/platform=macOS' \
ARCHS="arm64 x86_64" \
ONLY_ACTIVE_ARCH=NO \
CODE_SIGN_IDENTITY="-" \
CODE_SIGNING_REQUIRED=NO

View file

@ -4,12 +4,19 @@ A native macOS menu bar app for monitoring your **Torn** game status.
![macOS](https://img.shields.io/badge/macOS-13.0+-blue)
![Swift](https://img.shields.io/badge/Swift-5.0-orange)
![Universal](https://img.shields.io/badge/Universal-Intel%20%2B%20Apple%20Silicon-purple)
![License](https://img.shields.io/badge/License-MIT-green)
<p align="center">
<img src="app.png?v=1.2" alt="MacTorn Screenshot" width="600">
<img src="app_light_1.png" alt="MacTorn Light Mode" width="320">
&nbsp;&nbsp;
<img src="app_dark_1.png" alt="MacTorn Dark Mode" width="320">
</p>
## Documentation
For detailed documentation, visit the [MacTorn Wiki](https://github.com/pawelorzech/MacTorn/wiki).
## Features
### 📊 Status Tab
@ -20,7 +27,14 @@ A native macOS menu bar app for monitoring your **Torn** game status.
- Hospital/Jail status badges
- Unread messages badge
- Events feed
- 8 customizable quick links
- 8 quick links
### ✈️ Travel Tab
- **Live countdown timer** in menu bar during flight (✈️🇺🇸 5:32)
- Flight status with progress bar
- Quick travel destination picker (all 11 Torn destinations)
- Pre-arrival notifications (configurable: 2min, 1min, 30sec, 10sec)
- Country flags for all destinations
### 💰 Money Tab
- Cash, Vault, Points, Tokens display
@ -49,6 +63,14 @@ A native macOS menu bar app for monitoring your **Torn** game status.
- **🚀 Launch at Login**: Start seamlessly with macOS.
- **⚡️ Optimized Startup**: Non-blocking data fetching for instant UI responsiveness.
## Accessibility
MacTorn respects macOS accessibility settings:
- **Reduce Transparency**: When enabled in System Settings → Accessibility → Display, the app uses solid backgrounds instead of translucent materials for better readability
- **Light & Dark Mode**: Full support for both appearance modes with optimized contrast
- **Color-coded indicators**: Status bars and badges use distinct colors that work well in both modes
## Installation
1. Download the latest release from [Releases](https://github.com/pawelorzech/MacTorn/releases)
@ -61,6 +83,7 @@ A native macOS menu bar app for monitoring your **Torn** game status.
## Requirements
- macOS 13.0 (Ventura) or later
- **Universal Binary**: Supports both Intel (x86_64) and Apple Silicon (arm64) Macs
- Torn API Key with access to: basic, bars, cooldowns, travel, profile, events, messages, market
## API Data Usage

BIN
app.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

BIN
app_dark_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

BIN
app_light_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB