Compare commits

...

13 commits
v1.4.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
37 changed files with 783 additions and 92 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

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

@ -30,6 +30,8 @@
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 */; };
@ -43,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 */
@ -89,6 +92,8 @@
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>"; };
@ -103,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>"; };
@ -155,6 +161,7 @@
AAA30005 /* Views */,
AAA30007 /* Utilities */,
AAA30008 /* Networking */,
AAA30009 /* Helpers */,
);
path = MacTorn;
sourceTree = "<group>";
@ -210,6 +217,7 @@
AAA10013 /* ChainView.swift */,
AAA10014 /* StatusBadgesView.swift */,
AAA10015 /* EventsView.swift */,
AAA10026 /* FeedbackPromptView.swift */,
);
path = Components;
sourceTree = "<group>";
@ -233,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;
@ -281,6 +297,7 @@
children = (
BBB10011 /* AppStateTests.swift */,
BBB10012 /* AppStateWatchlistTests.swift */,
BBB10013 /* AppStateFeedbackTests.swift */,
);
path = ViewModels;
sourceTree = "<group>";
@ -445,6 +462,8 @@
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;
};
@ -464,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;
};
@ -604,6 +624,7 @@
MTL_FAST_MATH = YES;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
ARCHS = "arm64 x86_64";
};
name = Release;
};
@ -626,7 +647,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.4.3;
MARKETING_VERSION = 1.4.7;
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
@ -653,7 +674,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.4.3;
MARKETING_VERSION = 1.4.7;
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
@ -671,7 +692,7 @@
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.4.3;
MARKETING_VERSION = 1.4.7;
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@ -689,7 +710,7 @@
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.4.3;
MARKETING_VERSION = 1.4.7;
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@ -707,7 +728,7 @@
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.4.3;
MARKETING_VERSION = 1.4.7;
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@ -724,7 +745,7 @@
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.4.3;
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,16 +3,37 @@ 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: {
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

View file

@ -115,6 +115,13 @@ struct Travel: Codable, Equatable {
/// 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)
@ -727,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

@ -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?
@ -30,6 +46,11 @@ 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()
@ -60,6 +81,7 @@ class AppState: ObservableObject {
loadNotificationRules()
loadTravelNotificationSettings()
loadWatchlist()
loadFeedbackState()
// Polling and permissions moved to onAppear in UI
}
@ -284,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
let secondPrice = sortedListings.count > 1 ? sortedListings[1].price : 0
await updateItemPrice(itemId: itemId, lowestPrice: best.price, lowestPriceQuantity: best.amount, secondLowestPrice: secondPrice)
} else {
watchlistItems[index].error = "No listings"
}
saveWatchlist()
}
await updateItemError(itemId: itemId, error: "No listings")
}
}
} catch {
@ -311,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()
}
}
@ -554,6 +578,9 @@ class AppState: ObservableObject {
// 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")")
@ -644,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)
}
}
@ -695,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
@ -91,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
@ -134,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
@ -149,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,6 +1,7 @@
import SwiftUI
struct ChainView: View {
@Environment(\.reduceTransparency) private var reduceTransparency
let chain: Chain
let fetchTime: Date
@ -25,7 +26,7 @@ struct ChainView: View {
}
}
.padding(8)
.background(color.opacity(0.1))
.background(color.opacity(reduceTransparency ? 0.4 : 0.1))
.cornerRadius(8)
}
} else if chain.isOnCooldown {

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

@ -22,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
@ -55,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()
@ -66,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 {
@ -106,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
}

View file

@ -1,6 +1,7 @@
import SwiftUI
struct CreditsView: View {
@Environment(\.reduceTransparency) private var reduceTransparency
@Binding var showCredits: Bool
// MARK: - Developer
@ -107,7 +108,7 @@ struct CreditsView: View {
.foregroundColor(.accentColor)
.padding(10)
.frame(maxWidth: .infinity)
.background(Color.orange.opacity(0.1))
.background(Color.orange.opacity(reduceTransparency ? 0.4 : 0.1))
.cornerRadius(8)
}
}
@ -139,7 +140,7 @@ struct CreditsView: View {
.foregroundColor(.accentColor)
.padding(10)
.frame(maxWidth: .infinity)
.background(Color.secondary.opacity(0.1))
.background(Color.secondary.opacity(reduceTransparency ? 0.4 : 0.1))
.cornerRadius(8)
}
}
@ -171,7 +172,7 @@ struct CreditsView: View {
.foregroundColor(.accentColor)
.padding(10)
.frame(maxWidth: .infinity)
.background(Color.secondary.opacity(0.1))
.background(Color.secondary.opacity(reduceTransparency ? 0.4 : 0.1))
.cornerRadius(8)
}
}
@ -199,7 +200,7 @@ struct CreditsView: View {
}
.padding(10)
.frame(maxWidth: .infinity)
.background(Color.secondary.opacity(0.1))
.background(Color.secondary.opacity(reduceTransparency ? 0.4 : 0.1))
.cornerRadius(8)
}
}

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,6 +2,8 @@ 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
@ -88,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)
@ -116,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
@ -146,7 +172,7 @@ 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)
}

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 {
@ -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)
@ -150,7 +151,7 @@ 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 }
}
@ -240,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)

View file

@ -4,6 +4,7 @@ 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
@ -60,7 +61,7 @@ struct FlyingStatusView: View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.gray.opacity(0.2))
.fill(Color.gray.opacity(reduceTransparency ? 0.5 : 0.2))
.frame(height: 8)
RoundedRectangle(cornerRadius: 4)
@ -72,7 +73,7 @@ struct FlyingStatusView: View {
}
}
.padding()
.background(Color.blue.opacity(0.1))
.background(Color.blue.opacity(reduceTransparency ? 0.2 : 0.1))
.cornerRadius(12)
.transaction { $0.animation = nil }
}
@ -80,6 +81,7 @@ struct FlyingStatusView: View {
struct TravelView: View {
@EnvironmentObject var appState: AppState
@Environment(\.reduceTransparency) private var reduceTransparency
var body: some View {
ScrollView {
@ -164,7 +166,7 @@ struct TravelView: View {
.buttonStyle(.plain)
}
.padding()
.background(Color.orange.opacity(0.1))
.background(Color.orange.opacity(reduceTransparency ? 0.2 : 0.1))
.cornerRadius(12)
}
@ -183,7 +185,7 @@ struct TravelView: View {
Spacer()
}
.padding()
.background(Color.green.opacity(0.1))
.background(Color.green.opacity(reduceTransparency ? 0.2 : 0.1))
.cornerRadius(12)
}
@ -212,7 +214,7 @@ struct TravelView: View {
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(Color.accentColor.opacity(0.1))
.background(Color.accentColor.opacity(reduceTransparency ? 0.2 : 0.1))
.cornerRadius(8)
}
.buttonStyle(.plain)
@ -248,7 +250,7 @@ struct TravelView: View {
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.accentColor.opacity(0.1))
.background(Color.accentColor.opacity(reduceTransparency ? 0.2 : 0.1))
.cornerRadius(8)
}
.buttonStyle(.plain)
@ -283,7 +285,7 @@ struct TravelView: View {
}
}
.padding()
.background(Color.secondary.opacity(0.1))
.background(Color.secondary.opacity(reduceTransparency ? 0.2 : 0.1))
.cornerRadius(8)
}
}
@ -312,7 +314,7 @@ struct TravelView: View {
.font(.caption)
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.accentColor.opacity(0.1))
.background(Color.accentColor.opacity(reduceTransparency ? 0.2 : 0.1))
.cornerRadius(6)
}
.buttonStyle(.plain)
@ -329,7 +331,7 @@ struct TravelView: View {
.font(.caption)
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color.accentColor.opacity(0.1))
.background(Color.accentColor.opacity(reduceTransparency ? 0.2 : 0.1))
.cornerRadius(6)
}
.buttonStyle(.plain)

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,7 @@ 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)
@ -56,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)
@ -68,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