mirror of
https://github.com/pawelorzech/MacTorn.git
synced 2026-01-30 04:04:27 +00:00
Compare commits
25 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e214a0b19 | ||
|
|
b1804e5a69 | ||
|
|
63cef35384 | ||
|
|
e4c8f6927b | ||
|
|
a55be3c6be | ||
|
|
a2d3e6416f | ||
|
|
715f0877ff | ||
|
|
e10add9474 | ||
|
|
9724bcbacb | ||
|
|
8a4fb30cad | ||
|
|
4414a2696a | ||
|
|
21ac399269 | ||
|
|
273fd31884 | ||
|
|
7f836a0bbd | ||
|
|
7cca5dd896 | ||
|
|
f9a2b63ab4 | ||
|
|
7c4059e59a | ||
|
|
57be14f6c3 | ||
|
|
0764a38bb8 | ||
|
|
71db6d4db1 | ||
|
|
2e485c05d1 | ||
|
|
bab929df98 | ||
|
|
14439f50ff | ||
|
|
e781a767b3 | ||
|
|
33857f8cfe |
39 changed files with 1953 additions and 176 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
|
||||
Binary file not shown.
BIN
MacTorn-v1.4.4.zip
Normal file
BIN
MacTorn-v1.4.4.zip
Normal file
Binary file not shown.
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.
|
|
@ -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;
|
||||
|
|
|
|||
13
MacTorn/MacTorn/Helpers/TransparencyEnvironment.swift
Normal file
13
MacTorn/MacTorn/Helpers/TransparencyEnvironment.swift
Normal 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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -17,8 +17,8 @@
|
|||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.2.5</string>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.2.5</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
274
MacTorn/MacTorn/Views/CreditsView.swift
Normal file
274
MacTorn/MacTorn/Views/CreditsView.swift
Normal 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))
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -201,9 +203,15 @@ struct StatusView: View {
|
|||
.foregroundColor(.secondary)
|
||||
|
||||
HStack(spacing: 16) {
|
||||
CooldownItem(label: "Drug", seconds: cooldowns.drug, icon: "pills.fill")
|
||||
CooldownItem(label: "Medical", seconds: cooldowns.medical, icon: "cross.case.fill")
|
||||
CooldownItem(label: "Booster", seconds: cooldowns.booster, icon: "arrow.up.circle.fill")
|
||||
if let fetchTime = appState.lastUpdated {
|
||||
LiveCooldownItem(label: "Drug", originalSeconds: cooldowns.drug, fetchTime: fetchTime, icon: "pills.fill")
|
||||
LiveCooldownItem(label: "Medical", originalSeconds: cooldowns.medical, fetchTime: fetchTime, icon: "cross.case.fill")
|
||||
LiveCooldownItem(label: "Booster", originalSeconds: cooldowns.booster, fetchTime: fetchTime, icon: "arrow.up.circle.fill")
|
||||
} else {
|
||||
CooldownItem(label: "Drug", seconds: cooldowns.drug, icon: "pills.fill")
|
||||
CooldownItem(label: "Medical", seconds: cooldowns.medical, icon: "cross.case.fill")
|
||||
CooldownItem(label: "Booster", seconds: cooldowns.booster, icon: "arrow.up.circle.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -233,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)
|
||||
|
|
@ -263,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)
|
||||
|
||||
|
|
@ -286,3 +294,41 @@ struct CooldownItem: View {
|
|||
return String(format: "%d:%02d", minutes, secs)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Live Cooldown Item
|
||||
struct LiveCooldownItem: View {
|
||||
let label: String
|
||||
let originalSeconds: Int
|
||||
let fetchTime: Date
|
||||
let icon: String
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.periodic(from: fetchTime, by: 1.0)) { context in
|
||||
let elapsed = Int(context.date.timeIntervalSince(fetchTime))
|
||||
let remaining = max(0, originalSeconds - elapsed)
|
||||
|
||||
VStack(spacing: 2) {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundColor(remaining > 0 ? .orange : .green)
|
||||
|
||||
Text(formattedTime(remaining))
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundColor(remaining > 0 ? .primary : .green)
|
||||
.fontWeight(remaining <= 0 ? .bold : .regular)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private func formattedTime(_ seconds: Int) -> String {
|
||||
if seconds <= 0 { return "Ready" }
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
353
MacTorn/MacTorn/Views/TravelView.swift
Normal file
353
MacTorn/MacTorn/Views/TravelView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
6
Makefile
6
Makefile
|
|
@ -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
|
||||
|
||||
|
|
|
|||
27
README.md
27
README.md
|
|
@ -4,12 +4,19 @@ A native macOS menu bar app for monitoring your **Torn** game status.
|
|||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
<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">
|
||||
|
||||
<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
BIN
app.png
Binary file not shown.
|
Before Width: | Height: | Size: 246 KiB |
BIN
app_dark_1.png
Normal file
BIN
app_dark_1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 233 KiB |
BIN
app_light_1.png
Normal file
BIN
app_light_1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 226 KiB |
Loading…
Reference in a new issue