mirror of
https://github.com/pawelorzech/MacTorn.git
synced 2026-01-29 19:54:27 +00:00
Compare commits
11 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e214a0b19 | ||
|
|
b1804e5a69 | ||
|
|
63cef35384 | ||
|
|
e4c8f6927b | ||
|
|
a55be3c6be | ||
|
|
a2d3e6416f | ||
|
|
715f0877ff | ||
|
|
e10add9474 | ||
|
|
9724bcbacb | ||
|
|
8a4fb30cad | ||
|
|
4414a2696a |
16 changed files with 600 additions and 31 deletions
BIN
.DS_Store
vendored
BIN
.DS_Store
vendored
Binary file not shown.
15
.claude/commands/new-version.md
Normal file
15
.claude/commands/new-version.md
Normal 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
3
.gitignore
vendored
|
|
@ -68,3 +68,6 @@ DerivedData/
|
|||
|
||||
# Archive folder (old releases)
|
||||
archive/
|
||||
|
||||
# Claude Code
|
||||
CLAUDE.md
|
||||
|
|
|
|||
73
CHANGELOG.md
Normal file
73
CHANGELOG.md
Normal 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.5.zip
Normal file
BIN
MacTorn-v1.4.5.zip
Normal file
Binary file not shown.
BIN
MacTorn-v1.4.6.zip
Normal file
BIN
MacTorn-v1.4.6.zip
Normal file
Binary file not shown.
BIN
MacTorn-v1.4.7.zip
Normal file
BIN
MacTorn-v1.4.7.zip
Normal file
Binary file not shown.
BIN
MacTorn/.DS_Store
vendored
BIN
MacTorn/.DS_Store
vendored
Binary file not shown.
|
|
@ -31,6 +31,7 @@
|
|||
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 */; };
|
||||
|
|
@ -44,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 */
|
||||
|
|
@ -91,6 +93,7 @@
|
|||
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>"; };
|
||||
|
|
@ -105,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>"; };
|
||||
|
|
@ -213,6 +217,7 @@
|
|||
AAA10013 /* ChainView.swift */,
|
||||
AAA10014 /* StatusBadgesView.swift */,
|
||||
AAA10015 /* EventsView.swift */,
|
||||
AAA10026 /* FeedbackPromptView.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -292,6 +297,7 @@
|
|||
children = (
|
||||
BBB10011 /* AppStateTests.swift */,
|
||||
BBB10012 /* AppStateWatchlistTests.swift */,
|
||||
BBB10013 /* AppStateFeedbackTests.swift */,
|
||||
);
|
||||
path = ViewModels;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -457,6 +463,7 @@
|
|||
AAA00022 /* TravelView.swift in Sources */,
|
||||
AAA00023 /* CreditsView.swift in Sources */,
|
||||
AAA00024 /* TransparencyEnvironment.swift in Sources */,
|
||||
AAA00025 /* FeedbackPromptView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
@ -476,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;
|
||||
};
|
||||
|
|
@ -639,7 +647,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.4;
|
||||
MARKETING_VERSION = 1.4.7;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
|
|
@ -666,7 +674,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.4.4;
|
||||
MARKETING_VERSION = 1.4.7;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
|
|
@ -684,7 +692,7 @@
|
|||
DEVELOPMENT_TEAM = "";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.4.4;
|
||||
MARKETING_VERSION = 1.4.7;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
|
|
@ -702,7 +710,7 @@
|
|||
DEVELOPMENT_TEAM = "";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.4.4;
|
||||
MARKETING_VERSION = 1.4.7;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
|
|
@ -720,7 +728,7 @@
|
|||
DEVELOPMENT_TEAM = "";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.4.4;
|
||||
MARKETING_VERSION = 1.4.7;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
|
|
@ -737,7 +745,7 @@
|
|||
DEVELOPMENT_TEAM = "";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.4.4;
|
||||
MARKETING_VERSION = 1.4.7;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -46,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()
|
||||
|
||||
|
|
@ -76,6 +81,7 @@ class AppState: ObservableObject {
|
|||
loadNotificationRules()
|
||||
loadTravelNotificationSettings()
|
||||
loadWatchlist()
|
||||
loadFeedbackState()
|
||||
// Polling and permissions moved to onAppear in UI
|
||||
}
|
||||
|
||||
|
|
@ -299,26 +305,12 @@ 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 {
|
||||
|
|
@ -327,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()
|
||||
}
|
||||
}
|
||||
|
|
@ -570,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")")
|
||||
|
|
@ -660,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)
|
||||
}
|
||||
}
|
||||
|
|
@ -702,7 +713,7 @@ class AppState: ObservableObject {
|
|||
// MARK: - Updates
|
||||
func checkForAppUpdates() {
|
||||
guard let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String else { return }
|
||||
|
||||
|
||||
Task {
|
||||
if let release = await updateManager.checkForUpdates(currentVersion: currentVersion) {
|
||||
await MainActor.run {
|
||||
|
|
@ -711,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
|
||||
|
|
|
|||
74
MacTorn/MacTorn/Views/Components/FeedbackPromptView.swift
Normal file
74
MacTorn/MacTorn/Views/Components/FeedbackPromptView.swift
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -58,7 +58,7 @@ struct ContentView: View {
|
|||
if appState.isLoading && appState.lastUpdated == nil {
|
||||
(reduceTransparency ? Color(.windowBackgroundColor) : Color.black.opacity(0.4))
|
||||
.background(reduceTransparency ? AnyShapeStyle(Color(.windowBackgroundColor)) : AnyShapeStyle(.ultraThinMaterial))
|
||||
|
||||
|
||||
VStack(spacing: 12) {
|
||||
ProgressView()
|
||||
.controlSize(.large)
|
||||
|
|
@ -67,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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
186
MacTorn/MacTornTests/ViewModels/AppStateFeedbackTests.swift
Normal file
186
MacTorn/MacTornTests/ViewModels/AppStateFeedbackTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,10 @@ A native macOS menu bar app for monitoring your **Torn** game status.
|
|||
<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
|
||||
|
|
@ -23,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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue