Compare commits
No commits in common. "main" and "v1.4.2" have entirely different histories.
40 changed files with 134 additions and 1104 deletions
BIN
.DS_Store
vendored
Normal file
BIN
.DS_Store
vendored
Normal file
Binary file not shown.
|
|
@ -1,15 +0,0 @@
|
||||||
---
|
|
||||||
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.
|
|
||||||
44
.github/workflows/claude-code-review.yml
vendored
44
.github/workflows/claude-code-review.yml
vendored
|
|
@ -1,44 +0,0 @@
|
||||||
name: Claude Code Review
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [opened, synchronize, ready_for_review, reopened]
|
|
||||||
# Optional: Only run on specific file changes
|
|
||||||
# paths:
|
|
||||||
# - "src/**/*.ts"
|
|
||||||
# - "src/**/*.tsx"
|
|
||||||
# - "src/**/*.js"
|
|
||||||
# - "src/**/*.jsx"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
claude-review:
|
|
||||||
# Optional: Filter by PR author
|
|
||||||
# if: |
|
|
||||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
|
||||||
# github.event.pull_request.user.login == 'new-developer' ||
|
|
||||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: read
|
|
||||||
issues: read
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Run Claude Code Review
|
|
||||||
id: claude-review
|
|
||||||
uses: anthropics/claude-code-action@v1
|
|
||||||
with:
|
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
||||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
|
||||||
plugins: 'code-review@claude-code-plugins'
|
|
||||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
|
||||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
|
||||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
|
||||||
|
|
||||||
50
.github/workflows/claude.yml
vendored
50
.github/workflows/claude.yml
vendored
|
|
@ -1,50 +0,0 @@
|
||||||
name: Claude Code
|
|
||||||
|
|
||||||
on:
|
|
||||||
issue_comment:
|
|
||||||
types: [created]
|
|
||||||
pull_request_review_comment:
|
|
||||||
types: [created]
|
|
||||||
issues:
|
|
||||||
types: [opened, assigned]
|
|
||||||
pull_request_review:
|
|
||||||
types: [submitted]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
claude:
|
|
||||||
if: |
|
|
||||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
|
||||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
|
||||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
|
||||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: read
|
|
||||||
issues: read
|
|
||||||
id-token: write
|
|
||||||
actions: read # Required for Claude to read CI results on PRs
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Run Claude Code
|
|
||||||
id: claude
|
|
||||||
uses: anthropics/claude-code-action@v1
|
|
||||||
with:
|
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
||||||
|
|
||||||
# This is an optional setting that allows Claude to read CI results on PRs
|
|
||||||
additional_permissions: |
|
|
||||||
actions: read
|
|
||||||
|
|
||||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
|
||||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
|
||||||
|
|
||||||
# Optional: Add claude_args to customize behavior and configuration
|
|
||||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
|
||||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
|
||||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
|
||||||
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -68,6 +68,3 @@ DerivedData/
|
||||||
|
|
||||||
# Archive folder (old releases)
|
# Archive folder (old releases)
|
||||||
archive/
|
archive/
|
||||||
|
|
||||||
# Claude Code
|
|
||||||
CLAUDE.md
|
|
||||||
|
|
|
||||||
88
CHANGELOG.md
88
CHANGELOG.md
|
|
@ -1,88 +0,0 @@
|
||||||
# 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.5.1] - 2026-02-04
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Expanded browser support in browser picker with additional browser options
|
|
||||||
|
|
||||||
## [1.5.0] - 2026-02-04
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Preferred browser support for opening Torn links (system default browser selection)
|
|
||||||
- GitHub Actions integration with Claude Code for automated PR assistance and code review
|
|
||||||
- BrowserManager utility for managing browser preferences across the app
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Improved link handling to respect user's default browser choice
|
|
||||||
|
|
||||||
## [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/.DS_Store
vendored
Normal file
BIN
MacTorn/.DS_Store
vendored
Normal file
Binary file not shown.
|
|
@ -30,9 +30,6 @@
|
||||||
AAA00021 /* NetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10022 /* NetworkSession.swift */; };
|
AAA00021 /* NetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10022 /* NetworkSession.swift */; };
|
||||||
AAA00022 /* TravelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10023 /* TravelView.swift */; };
|
AAA00022 /* TravelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10023 /* TravelView.swift */; };
|
||||||
AAA00023 /* CreditsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10024 /* CreditsView.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 */; };
|
|
||||||
AAA00026 /* BrowserManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10027 /* BrowserManager.swift */; };
|
|
||||||
/* Unit Tests */
|
/* Unit Tests */
|
||||||
BBB00001 /* MockNetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10001 /* MockNetworkSession.swift */; };
|
BBB00001 /* MockNetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10001 /* MockNetworkSession.swift */; };
|
||||||
BBB00002 /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10002 /* TestHelpers.swift */; };
|
BBB00002 /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10002 /* TestHelpers.swift */; };
|
||||||
|
|
@ -46,7 +43,6 @@
|
||||||
BBB00010 /* MoneyDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10010 /* MoneyDataTests.swift */; };
|
BBB00010 /* MoneyDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10010 /* MoneyDataTests.swift */; };
|
||||||
BBB00011 /* AppStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10011 /* AppStateTests.swift */; };
|
BBB00011 /* AppStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10011 /* AppStateTests.swift */; };
|
||||||
BBB00012 /* AppStateWatchlistTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB10012 /* AppStateWatchlistTests.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 */
|
/* UI Tests */
|
||||||
CCC00001 /* MacTornUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCC10001 /* MacTornUITests.swift */; };
|
CCC00001 /* MacTornUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCC10001 /* MacTornUITests.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
@ -93,9 +89,6 @@
|
||||||
AAA10022 /* NetworkSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkSession.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>"; };
|
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>"; };
|
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>"; };
|
|
||||||
AAA10027 /* BrowserManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserManager.swift; sourceTree = "<group>"; };
|
|
||||||
AAA10000 /* MacTorn.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacTorn.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
AAA10000 /* MacTorn.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacTorn.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* Unit Test Files */
|
/* Unit Test Files */
|
||||||
BBB10001 /* MockNetworkSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNetworkSession.swift; sourceTree = "<group>"; };
|
BBB10001 /* MockNetworkSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNetworkSession.swift; sourceTree = "<group>"; };
|
||||||
|
|
@ -110,7 +103,6 @@
|
||||||
BBB10010 /* MoneyDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoneyDataTests.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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; };
|
BBB10000 /* MacTornTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MacTornTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* UI Test Files */
|
/* UI Test Files */
|
||||||
CCC10001 /* MacTornUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacTornUITests.swift; sourceTree = "<group>"; };
|
CCC10001 /* MacTornUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacTornUITests.swift; sourceTree = "<group>"; };
|
||||||
|
|
@ -163,7 +155,6 @@
|
||||||
AAA30005 /* Views */,
|
AAA30005 /* Views */,
|
||||||
AAA30007 /* Utilities */,
|
AAA30007 /* Utilities */,
|
||||||
AAA30008 /* Networking */,
|
AAA30008 /* Networking */,
|
||||||
AAA30009 /* Helpers */,
|
|
||||||
);
|
);
|
||||||
path = MacTorn;
|
path = MacTorn;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
|
@ -219,7 +210,6 @@
|
||||||
AAA10013 /* ChainView.swift */,
|
AAA10013 /* ChainView.swift */,
|
||||||
AAA10014 /* StatusBadgesView.swift */,
|
AAA10014 /* StatusBadgesView.swift */,
|
||||||
AAA10015 /* EventsView.swift */,
|
AAA10015 /* EventsView.swift */,
|
||||||
AAA10026 /* FeedbackPromptView.swift */,
|
|
||||||
);
|
);
|
||||||
path = Components;
|
path = Components;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
|
@ -228,7 +218,6 @@
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
AAA10009 /* NotificationManager.swift */,
|
AAA10009 /* NotificationManager.swift */,
|
||||||
AAA10027 /* BrowserManager.swift */,
|
|
||||||
AAA10011 /* LaunchAtLoginManager.swift */,
|
AAA10011 /* LaunchAtLoginManager.swift */,
|
||||||
AAA10012 /* ShortcutsManager.swift */,
|
AAA10012 /* ShortcutsManager.swift */,
|
||||||
AAA10016 /* SoundManager.swift */,
|
AAA10016 /* SoundManager.swift */,
|
||||||
|
|
@ -244,14 +233,6 @@
|
||||||
path = Networking;
|
path = Networking;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
AAA30009 /* Helpers */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
AAA10025 /* TransparencyEnvironment.swift */,
|
|
||||||
);
|
|
||||||
path = Helpers;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
/* Unit Tests Groups */
|
/* Unit Tests Groups */
|
||||||
BBB30000 /* MacTornTests */ = {
|
BBB30000 /* MacTornTests */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
|
|
@ -300,7 +281,6 @@
|
||||||
children = (
|
children = (
|
||||||
BBB10011 /* AppStateTests.swift */,
|
BBB10011 /* AppStateTests.swift */,
|
||||||
BBB10012 /* AppStateWatchlistTests.swift */,
|
BBB10012 /* AppStateWatchlistTests.swift */,
|
||||||
BBB10013 /* AppStateFeedbackTests.swift */,
|
|
||||||
);
|
);
|
||||||
path = ViewModels;
|
path = ViewModels;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
|
@ -465,9 +445,6 @@
|
||||||
AAA00021 /* NetworkSession.swift in Sources */,
|
AAA00021 /* NetworkSession.swift in Sources */,
|
||||||
AAA00022 /* TravelView.swift in Sources */,
|
AAA00022 /* TravelView.swift in Sources */,
|
||||||
AAA00023 /* CreditsView.swift in Sources */,
|
AAA00023 /* CreditsView.swift in Sources */,
|
||||||
AAA00024 /* TransparencyEnvironment.swift in Sources */,
|
|
||||||
AAA00025 /* FeedbackPromptView.swift in Sources */,
|
|
||||||
AAA00026 /* BrowserManager.swift in Sources */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|
@ -487,7 +464,6 @@
|
||||||
BBB00010 /* MoneyDataTests.swift in Sources */,
|
BBB00010 /* MoneyDataTests.swift in Sources */,
|
||||||
BBB00011 /* AppStateTests.swift in Sources */,
|
BBB00011 /* AppStateTests.swift in Sources */,
|
||||||
BBB00012 /* AppStateWatchlistTests.swift in Sources */,
|
BBB00012 /* AppStateWatchlistTests.swift in Sources */,
|
||||||
BBB00013 /* AppStateFeedbackTests.swift in Sources */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|
@ -628,7 +604,6 @@
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
ARCHS = "arm64 x86_64";
|
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
|
@ -651,7 +626,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.5.1;
|
MARKETING_VERSION = 1.4.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.app;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
|
@ -678,7 +653,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.5.1;
|
MARKETING_VERSION = 1.4.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.app;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
|
@ -696,7 +671,7 @@
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.5.1;
|
MARKETING_VERSION = 1.4.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
|
|
@ -714,7 +689,7 @@
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.5.1;
|
MARKETING_VERSION = 1.4.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
|
|
@ -732,7 +707,7 @@
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.5.1;
|
MARKETING_VERSION = 1.4.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornUITests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornUITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
|
|
@ -749,7 +724,7 @@
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.5.1;
|
MARKETING_VERSION = 1.4.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornUITests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.mactorn.MacTornUITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
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>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>$(MARKETING_VERSION)</string>
|
<string>1.3</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
<string>1.3</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
|
|
@ -3,37 +3,16 @@ import SwiftUI
|
||||||
@main
|
@main
|
||||||
struct MacTornApp: App {
|
struct MacTornApp: App {
|
||||||
@StateObject private var appState = AppState()
|
@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 {
|
var body: some Scene {
|
||||||
MenuBarExtra {
|
MenuBarExtra {
|
||||||
ContentView()
|
ContentView()
|
||||||
.environmentObject(appState)
|
.environmentObject(appState)
|
||||||
.environment(\.reduceTransparency, reduceTransparency)
|
|
||||||
.onAppear {
|
|
||||||
updateAppearance()
|
|
||||||
}
|
|
||||||
.onChange(of: appearanceModeRaw) {
|
|
||||||
updateAppearance()
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
MenuBarLabel(appState: appState)
|
MenuBarLabel(appState: appState)
|
||||||
}
|
}
|
||||||
.menuBarExtraStyle(.window)
|
.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
|
// MARK: - Menu Bar Label
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: - Constants
|
|
||||||
enum TornConstants {
|
|
||||||
static let developerID = 2362436
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Root Response
|
// MARK: - Root Response
|
||||||
struct TornResponse: Codable {
|
struct TornResponse: Codable {
|
||||||
let name: String?
|
let name: String?
|
||||||
|
|
@ -120,13 +115,6 @@ struct Travel: Codable, Equatable {
|
||||||
|
|
||||||
/// Calculate remaining seconds based on fetch time (for live countdown)
|
/// Calculate remaining seconds based on fetch time (for live countdown)
|
||||||
func remainingSeconds(from fetchTime: Date) -> Int {
|
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 }
|
guard let timeLeft = timeLeft, timeLeft > 0 else { return 0 }
|
||||||
let elapsed = Int(Date().timeIntervalSince(fetchTime))
|
let elapsed = Int(Date().timeIntervalSince(fetchTime))
|
||||||
return max(0, timeLeft - elapsed)
|
return max(0, timeLeft - elapsed)
|
||||||
|
|
@ -378,7 +366,7 @@ struct AttackResult: Codable, Identifiable {
|
||||||
let result: String?
|
let result: String?
|
||||||
let respect: Double?
|
let respect: Double?
|
||||||
|
|
||||||
let id: String
|
var id: String { code ?? UUID().uuidString }
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case code
|
case code
|
||||||
|
|
@ -391,33 +379,6 @@ struct AttackResult: Codable, Identifiable {
|
||||||
case result, respect
|
case result, respect
|
||||||
}
|
}
|
||||||
|
|
||||||
init(code: String?, timestampStarted: Int?, timestampEnded: Int?, attackerId: Int?, attackerName: String?, defenderId: Int?, defenderName: String?, result: String?, respect: Double?) {
|
|
||||||
self.code = code
|
|
||||||
self.timestampStarted = timestampStarted
|
|
||||||
self.timestampEnded = timestampEnded
|
|
||||||
self.attackerId = attackerId
|
|
||||||
self.attackerName = attackerName
|
|
||||||
self.defenderId = defenderId
|
|
||||||
self.defenderName = defenderName
|
|
||||||
self.result = result
|
|
||||||
self.respect = respect
|
|
||||||
self.id = code ?? UUID().uuidString
|
|
||||||
}
|
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
code = try container.decodeIfPresent(String.self, forKey: .code)
|
|
||||||
timestampStarted = try container.decodeIfPresent(Int.self, forKey: .timestampStarted)
|
|
||||||
timestampEnded = try container.decodeIfPresent(Int.self, forKey: .timestampEnded)
|
|
||||||
attackerId = try container.decodeIfPresent(Int.self, forKey: .attackerId)
|
|
||||||
attackerName = try container.decodeIfPresent(String.self, forKey: .attackerName)
|
|
||||||
defenderId = try container.decodeIfPresent(Int.self, forKey: .defenderId)
|
|
||||||
defenderName = try container.decodeIfPresent(String.self, forKey: .defenderName)
|
|
||||||
result = try container.decodeIfPresent(String.self, forKey: .result)
|
|
||||||
respect = try container.decodeIfPresent(Double.self, forKey: .respect)
|
|
||||||
id = code ?? UUID().uuidString
|
|
||||||
}
|
|
||||||
|
|
||||||
func opponentName(forUserId userId: Int) -> String {
|
func opponentName(forUserId userId: Int) -> String {
|
||||||
let name: String?
|
let name: String?
|
||||||
if attackerId == userId {
|
if attackerId == userId {
|
||||||
|
|
@ -766,14 +727,6 @@ 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
|
// MARK: - Keyboard Shortcuts
|
||||||
struct KeyboardShortcut: Identifiable, Codable, Equatable {
|
struct KeyboardShortcut: Identifiable, Codable, Equatable {
|
||||||
let id: String
|
let id: String
|
||||||
|
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
import AppKit
|
|
||||||
|
|
||||||
enum PreferredBrowser: String, CaseIterable, Identifiable {
|
|
||||||
case system = "System Default"
|
|
||||||
case safari = "Safari"
|
|
||||||
case chrome = "Google Chrome"
|
|
||||||
case firefox = "Firefox"
|
|
||||||
case edge = "Microsoft Edge"
|
|
||||||
case brave = "Brave"
|
|
||||||
case arc = "Arc"
|
|
||||||
case vivaldi = "Vivaldi"
|
|
||||||
case zen = "Zen"
|
|
||||||
case opera = "Opera"
|
|
||||||
case duckduckgo = "DuckDuckGo"
|
|
||||||
case orion = "Orion"
|
|
||||||
case tor = "Tor Browser"
|
|
||||||
case chromium = "Chromium"
|
|
||||||
case librewolf = "LibreWolf"
|
|
||||||
case waterfox = "Waterfox"
|
|
||||||
case atlas = "ChatGPT Atlas"
|
|
||||||
|
|
||||||
var id: String { rawValue }
|
|
||||||
|
|
||||||
var bundleIdentifiers: [String]? {
|
|
||||||
switch self {
|
|
||||||
case .system:
|
|
||||||
return nil
|
|
||||||
case .safari:
|
|
||||||
return ["com.apple.Safari"]
|
|
||||||
case .chrome:
|
|
||||||
return ["com.google.Chrome"]
|
|
||||||
case .firefox:
|
|
||||||
return ["org.mozilla.firefox"]
|
|
||||||
case .edge:
|
|
||||||
return ["com.microsoft.edgemac"]
|
|
||||||
case .brave:
|
|
||||||
return ["com.brave.Browser"]
|
|
||||||
case .arc:
|
|
||||||
return ["company.thebrowser.Browser"]
|
|
||||||
case .vivaldi:
|
|
||||||
return ["com.vivaldi.Vivaldi"]
|
|
||||||
case .zen:
|
|
||||||
return ["app.zen-browser.zen"]
|
|
||||||
case .opera:
|
|
||||||
return ["com.operasoftware.Opera"]
|
|
||||||
case .duckduckgo:
|
|
||||||
return ["com.duckduckgo.macos.browser"]
|
|
||||||
case .orion:
|
|
||||||
return ["com.kagi.kagimacOS", "com.kagi.kagimacOS.RC"]
|
|
||||||
case .tor:
|
|
||||||
return ["com.torproject.tor"]
|
|
||||||
case .chromium:
|
|
||||||
return ["org.chromium.Chromium"]
|
|
||||||
case .librewolf:
|
|
||||||
return ["io.gitlab.librewolf-community"]
|
|
||||||
case .waterfox:
|
|
||||||
return ["net.waterfox.waterfox"]
|
|
||||||
case .atlas:
|
|
||||||
return ["com.openai.atlas"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var installedApplicationURL: URL? {
|
|
||||||
guard let bundleIdentifiers else { return nil }
|
|
||||||
for bundleIdentifier in bundleIdentifiers {
|
|
||||||
if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) {
|
|
||||||
return appURL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var isInstalled: Bool {
|
|
||||||
self == .system || installedApplicationURL != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
static func availableBrowsers() -> [PreferredBrowser] {
|
|
||||||
PreferredBrowser.allCases.filter { $0.isInstalled }
|
|
||||||
}
|
|
||||||
|
|
||||||
init(storedValue: String?) {
|
|
||||||
guard let storedValue,
|
|
||||||
let value = PreferredBrowser(rawValue: storedValue) else {
|
|
||||||
self = .system
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class BrowserManager {
|
|
||||||
static let shared = BrowserManager()
|
|
||||||
|
|
||||||
private init() {}
|
|
||||||
|
|
||||||
func open(_ url: URL) {
|
|
||||||
guard let scheme = url.scheme,
|
|
||||||
["http", "https"].contains(scheme) else {
|
|
||||||
NSWorkspace.shared.open(url)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let preference = PreferredBrowser(storedValue: UserDefaults.standard.string(forKey: "preferredBrowser"))
|
|
||||||
guard let appURL = preference.installedApplicationURL else {
|
|
||||||
NSWorkspace.shared.open(url)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let configuration = NSWorkspace.OpenConfiguration()
|
|
||||||
NSWorkspace.shared.open([url], withApplicationAt: appURL, configuration: configuration, completionHandler: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -103,8 +103,14 @@ class NotificationManager: NSObject, UNUserNotificationCenterDelegate {
|
||||||
|
|
||||||
/// Cancel all travel-related notifications
|
/// Cancel all travel-related notifications
|
||||||
func cancelTravelNotifications() {
|
func cancelTravelNotifications() {
|
||||||
let identifiers = TravelNotificationSetting.defaults.map { "\($0.id)_alert" }
|
let identifiers = [
|
||||||
|
"travel_2min_alert",
|
||||||
|
"travel_1min_alert",
|
||||||
|
"travel_30sec_alert",
|
||||||
|
"travel_10sec_alert"
|
||||||
|
]
|
||||||
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: identifiers)
|
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: identifiers)
|
||||||
|
print("Cancelled travel notifications")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cancel a specific notification by identifier
|
/// Cancel a specific notification by identifier
|
||||||
|
|
@ -121,7 +127,7 @@ class NotificationManager: NSObject, UNUserNotificationCenterDelegate {
|
||||||
) {
|
) {
|
||||||
let categoryIdentifier = response.notification.request.content.categoryIdentifier
|
let categoryIdentifier = response.notification.request.content.categoryIdentifier
|
||||||
if let type = NotificationType(rawValue: categoryIdentifier) {
|
if let type = NotificationType(rawValue: categoryIdentifier) {
|
||||||
BrowserManager.shared.open(type.url)
|
NSWorkspace.shared.open(type.url)
|
||||||
}
|
}
|
||||||
completionHandler()
|
completionHandler()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,6 @@ class ShortcutsManager: ObservableObject {
|
||||||
|
|
||||||
func openURL(_ urlString: String) {
|
func openURL(_ urlString: String) {
|
||||||
guard let url = URL(string: urlString) else { return }
|
guard let url = URL(string: urlString) else { return }
|
||||||
BrowserManager.shared.open(url)
|
NSWorkspace.shared.open(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,27 +5,11 @@ import os.log
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "com.mactorn", category: "AppState")
|
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
|
@MainActor
|
||||||
class AppState: ObservableObject {
|
class AppState: ObservableObject {
|
||||||
// MARK: - Persisted
|
// MARK: - Persisted
|
||||||
@AppStorage("apiKey") var apiKey: String = ""
|
@AppStorage("apiKey") var apiKey: String = ""
|
||||||
@AppStorage("refreshInterval") var refreshInterval: Int = 30
|
@AppStorage("refreshInterval") var refreshInterval: Int = 30
|
||||||
@AppStorage("appearanceMode") var appearanceMode: String = AppearanceMode.system.rawValue
|
|
||||||
|
|
||||||
// MARK: - Published State
|
// MARK: - Published State
|
||||||
@Published var data: TornResponse?
|
@Published var data: TornResponse?
|
||||||
|
|
@ -46,11 +30,6 @@ class AppState: ObservableObject {
|
||||||
// MARK: - Update State
|
// MARK: - Update State
|
||||||
@Published var updateAvailable: GitHubRelease?
|
@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)
|
// MARK: - Fetch Time (for live countdown calculations)
|
||||||
@Published var lastFetchTime: Date = Date()
|
@Published var lastFetchTime: Date = Date()
|
||||||
|
|
||||||
|
|
@ -81,7 +60,6 @@ class AppState: ObservableObject {
|
||||||
loadNotificationRules()
|
loadNotificationRules()
|
||||||
loadTravelNotificationSettings()
|
loadTravelNotificationSettings()
|
||||||
loadWatchlist()
|
loadWatchlist()
|
||||||
loadFeedbackState()
|
|
||||||
// Polling and permissions moved to onAppear in UI
|
// Polling and permissions moved to onAppear in UI
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -306,15 +284,26 @@ class AppState: ObservableObject {
|
||||||
let sortedListings = allListings.sorted { $0.price < $1.price }
|
let sortedListings = allListings.sorted { $0.price < $1.price }
|
||||||
logger.debug("Item \(itemId): found \(sortedListings.count) listings, lowest: \(sortedListings.first?.price ?? 0)")
|
logger.debug("Item \(itemId): found \(sortedListings.count) listings, lowest: \(sortedListings.first?.price ?? 0)")
|
||||||
|
|
||||||
if let best = sortedListings.first {
|
await MainActor.run {
|
||||||
let secondPrice = sortedListings.count > 1 ? sortedListings[1].price : 0
|
if let index = watchlistItems.firstIndex(where: { $0.id == itemId }) {
|
||||||
await updateItemPrice(itemId: itemId, lowestPrice: best.price, lowestPriceQuantity: best.amount, secondLowestPrice: secondPrice)
|
if let best = sortedListings.first {
|
||||||
} else {
|
watchlistItems[index].lowestPrice = best.price
|
||||||
await updateItemError(itemId: itemId, error: "No listings")
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
logger.error("Item \(itemId): failed to parse JSON response")
|
|
||||||
await updateItemError(itemId: itemId, error: "Parse Error")
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Item \(itemId) price fetch error: \(error.localizedDescription)")
|
logger.error("Item \(itemId) price fetch error: \(error.localizedDescription)")
|
||||||
|
|
@ -322,26 +311,10 @@ 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
|
@MainActor
|
||||||
private func updateItemError(itemId: Int, error: String) {
|
private func updateItemError(itemId: Int, error: String) {
|
||||||
if let index = watchlistItems.firstIndex(where: { $0.id == itemId }) {
|
if let index = watchlistItems.firstIndex(where: { $0.id == itemId }) {
|
||||||
var item = watchlistItems[index]
|
watchlistItems[index].error = error
|
||||||
item.error = error
|
|
||||||
watchlistItems[index] = item
|
|
||||||
saveWatchlist()
|
saveWatchlist()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -581,10 +554,9 @@ class AppState: ObservableObject {
|
||||||
// Manage travel timer after data is set
|
// Manage travel timer after data is set
|
||||||
self.manageTravelTimer()
|
self.manageTravelTimer()
|
||||||
|
|
||||||
// Check if feedback prompt should be shown
|
// Force UI update by triggering objectWillChange
|
||||||
self.checkFeedbackPrompt()
|
self.objectWillChange.send()
|
||||||
|
logger.info("UI update triggered, lastUpdated: \(self.lastUpdated?.description ?? "nil")")
|
||||||
logger.info("Data updated, lastUpdated: \(self.lastUpdated?.description ?? "nil")")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -672,7 +644,7 @@ class AppState: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
if let prevStatus = previousStatus, let currentStatus = newData.status {
|
if let prevStatus = previousStatus, let currentStatus = newData.status {
|
||||||
if (prevStatus.isInHospital || prevStatus.isInJail) && currentStatus.isOkay {
|
if !prevStatus.isOkay && currentStatus.isOkay {
|
||||||
NotificationManager.shared.send(title: "Released! 🎉", body: "You are now free", type: .released)
|
NotificationManager.shared.send(title: "Released! 🎉", body: "You are now free", type: .released)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -723,73 +695,6 @@ 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") {
|
|
||||||
BrowserManager.shared.open(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func feedbackRespondedNegative() {
|
|
||||||
feedbackState?.hasResponded = true
|
|
||||||
showFeedbackPrompt = false
|
|
||||||
saveFeedbackState()
|
|
||||||
if let url = URL(string: "mailto:pawel@orzech.lol?subject=MacTorn%20Feedback") {
|
|
||||||
BrowserManager.shared.open(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func feedbackDismissed() {
|
|
||||||
feedbackState?.dismissCount += 1
|
|
||||||
feedbackState?.lastDismissedDate = Date()
|
|
||||||
showFeedbackPrompt = false
|
|
||||||
saveFeedbackState()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Errors
|
// MARK: - Errors
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import SwiftUI
|
||||||
|
|
||||||
struct AttacksView: View {
|
struct AttacksView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@Environment(\.reduceTransparency) private var reduceTransparency
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
|
|
@ -40,7 +39,7 @@ struct AttacksView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color.red.opacity(reduceTransparency ? 0.25 : 0.05))
|
.background(Color.red.opacity(0.05))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
|
|
||||||
// Recent Attacks
|
// Recent Attacks
|
||||||
|
|
@ -58,7 +57,7 @@ struct AttacksView: View {
|
||||||
Button {
|
Button {
|
||||||
if let opponentId = attack.opponentId(forUserId: userId),
|
if let opponentId = attack.opponentId(forUserId: userId),
|
||||||
let url = URL(string: "https://www.torn.com/profiles.php?XID=\(opponentId)") {
|
let url = URL(string: "https://www.torn.com/profiles.php?XID=\(opponentId)") {
|
||||||
BrowserManager.shared.open(url)
|
NSWorkspace.shared.open(url)
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
|
|
@ -92,7 +91,7 @@ struct AttacksView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color.orange.opacity(reduceTransparency ? 0.25 : 0.05))
|
.background(Color.orange.opacity(0.05))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
|
@ -128,14 +127,13 @@ struct AttacksView: View {
|
||||||
|
|
||||||
private func openURL(_ urlString: String) {
|
private func openURL(_ urlString: String) {
|
||||||
if let url = URL(string: urlString) {
|
if let url = URL(string: urlString) {
|
||||||
BrowserManager.shared.open(url)
|
NSWorkspace.shared.open(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Stat Item
|
// MARK: - Stat Item
|
||||||
struct StatItem: View {
|
struct StatItem: View {
|
||||||
@Environment(\.reduceTransparency) private var reduceTransparency
|
|
||||||
let label: String
|
let label: String
|
||||||
let value: String
|
let value: String
|
||||||
let color: Color
|
let color: Color
|
||||||
|
|
@ -151,7 +149,7 @@ struct StatItem: View {
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
.background(color.opacity(reduceTransparency ? 0.4 : 0.1))
|
.background(color.opacity(0.1))
|
||||||
.cornerRadius(4)
|
.cornerRadius(4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ChainView: View {
|
struct ChainView: View {
|
||||||
@Environment(\.reduceTransparency) private var reduceTransparency
|
|
||||||
let chain: Chain
|
let chain: Chain
|
||||||
let fetchTime: Date
|
let fetchTime: Date
|
||||||
|
|
||||||
|
|
@ -26,7 +25,7 @@ struct ChainView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(8)
|
.padding(8)
|
||||||
.background(color.opacity(reduceTransparency ? 0.4 : 0.1))
|
.background(color.opacity(0.1))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
}
|
}
|
||||||
} else if chain.isOnCooldown {
|
} else if chain.isOnCooldown {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct EventsView: View {
|
struct EventsView: View {
|
||||||
@Environment(\.reduceTransparency) private var reduceTransparency
|
|
||||||
let events: [TornEvent]
|
let events: [TornEvent]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|
@ -34,7 +33,7 @@ struct EventsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(8)
|
.padding(8)
|
||||||
.background(Color.blue.opacity(reduceTransparency ? 0.25 : 0.05))
|
.background(Color.blue.opacity(0.05))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
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,7 +1,6 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ProgressBarView: View {
|
struct ProgressBarView: View {
|
||||||
@Environment(\.reduceTransparency) private var reduceTransparency
|
|
||||||
let label: String
|
let label: String
|
||||||
let current: Int
|
let current: Int
|
||||||
let maximum: Int
|
let maximum: Int
|
||||||
|
|
@ -41,10 +40,10 @@ struct ProgressBarView: View {
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
// Background track
|
// Background track
|
||||||
RoundedRectangle(cornerRadius: 4)
|
RoundedRectangle(cornerRadius: 4)
|
||||||
.fill(Color.gray.opacity(reduceTransparency ? 0.5 : 0.3))
|
.fill(Color.gray.opacity(0.3))
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 4)
|
RoundedRectangle(cornerRadius: 4)
|
||||||
.stroke(color.opacity(reduceTransparency ? 0.5 : 0.3), lineWidth: 1)
|
.stroke(color.opacity(0.3), lineWidth: 1)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Filled progress
|
// Filled progress
|
||||||
|
|
@ -52,13 +51,13 @@ struct ProgressBarView: View {
|
||||||
RoundedRectangle(cornerRadius: 4)
|
RoundedRectangle(cornerRadius: 4)
|
||||||
.fill(
|
.fill(
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: [color, color.opacity(reduceTransparency ? 0.9 : 0.7)],
|
colors: [color, color.opacity(0.7)],
|
||||||
startPoint: .leading,
|
startPoint: .leading,
|
||||||
endPoint: .trailing
|
endPoint: .trailing
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.frame(width: max(4, geometry.size.width * progress))
|
.frame(width: max(4, geometry.size.width * progress))
|
||||||
.shadow(color: color.opacity(reduceTransparency ? 0.7 : 0.5), radius: 2, x: 0, y: 0)
|
.shadow(color: color.opacity(0.5), radius: 2, x: 0, y: 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct StatusBadgesView: View {
|
struct StatusBadgesView: View {
|
||||||
@Environment(\.reduceTransparency) private var reduceTransparency
|
|
||||||
let status: Status
|
let status: Status
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|
@ -19,7 +18,7 @@ struct StatusBadgesView: View {
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
.background(Color.red.opacity(reduceTransparency ? 0.4 : 0.1))
|
.background(Color.red.opacity(0.1))
|
||||||
.cornerRadius(6)
|
.cornerRadius(6)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,7 +34,7 @@ struct StatusBadgesView: View {
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
.background(Color.orange.opacity(reduceTransparency ? 0.4 : 0.1))
|
.background(Color.orange.opacity(0.1))
|
||||||
.cornerRadius(6)
|
.cornerRadius(6)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ enum AppTab: String, CaseIterable {
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@Environment(\.reduceTransparency) private var reduceTransparency
|
|
||||||
@State private var showSettings = false
|
@State private var showSettings = false
|
||||||
@State private var currentTab: AppTab = .status
|
@State private var currentTab: AppTab = .status
|
||||||
|
|
||||||
|
|
@ -56,8 +55,8 @@ struct ContentView: View {
|
||||||
|
|
||||||
// Loading Overlay
|
// Loading Overlay
|
||||||
if appState.isLoading && appState.lastUpdated == nil {
|
if appState.isLoading && appState.lastUpdated == nil {
|
||||||
(reduceTransparency ? Color(.windowBackgroundColor) : Color.black.opacity(0.4))
|
Color.black.opacity(0.4)
|
||||||
.background(reduceTransparency ? AnyShapeStyle(Color(.windowBackgroundColor)) : AnyShapeStyle(.ultraThinMaterial))
|
.background(.ultraThinMaterial)
|
||||||
|
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
|
|
@ -67,15 +66,6 @@ struct ContentView: View {
|
||||||
.foregroundColor(.secondary)
|
.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)
|
.frame(width: 320)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
|
@ -91,7 +81,7 @@ struct ContentView: View {
|
||||||
private var headerView: some View {
|
private var headerView: some View {
|
||||||
HStack {
|
HStack {
|
||||||
if let lastUpdated = appState.lastUpdated {
|
if let lastUpdated = appState.lastUpdated {
|
||||||
Text("Updated: \(lastUpdated, formatter: Self.timeFormatter)")
|
Text("Updated: \(lastUpdated, formatter: timeFormatter)")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
|
@ -116,7 +106,7 @@ struct ContentView: View {
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(currentTab == tab ? Color.accentColor.opacity(reduceTransparency ? 0.3 : 0.2) : Color.clear)
|
.background(currentTab == tab ? Color.accentColor.opacity(0.2) : Color.clear)
|
||||||
.cornerRadius(6)
|
.cornerRadius(6)
|
||||||
.contentShape(Rectangle()) // Make entire area clickable
|
.contentShape(Rectangle()) // Make entire area clickable
|
||||||
}
|
}
|
||||||
|
|
@ -177,9 +167,9 @@ struct ContentView: View {
|
||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static let timeFormatter: DateFormatter = {
|
private var timeFormatter: DateFormatter {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.timeStyle = .short
|
formatter.timeStyle = .short
|
||||||
return formatter
|
return formatter
|
||||||
}()
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct CreditsView: View {
|
struct CreditsView: View {
|
||||||
@Environment(\.reduceTransparency) private var reduceTransparency
|
|
||||||
@Binding var showCredits: Bool
|
@Binding var showCredits: Bool
|
||||||
|
|
||||||
// MARK: - Developer
|
// MARK: - Developer
|
||||||
private let developer = TornContributor(name: "bombel", tornID: TornConstants.developerID)
|
private let developer = TornContributor(name: "bombel", tornID: 2362436)
|
||||||
|
|
||||||
// MARK: - Special Thanks
|
// MARK: - Special Thanks
|
||||||
private let specialThanks: [TornContributor] = [
|
private let specialThanks: [TornContributor] = [
|
||||||
|
|
@ -92,9 +91,7 @@ struct CreditsView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
if let tornID = developer.tornID {
|
openTornProfile(developer.tornID!)
|
||||||
openTornProfile(tornID)
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
Text(developer.name)
|
Text(developer.name)
|
||||||
|
|
@ -110,7 +107,7 @@ struct CreditsView: View {
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(.accentColor)
|
||||||
.padding(10)
|
.padding(10)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(Color.orange.opacity(reduceTransparency ? 0.4 : 0.1))
|
.background(Color.orange.opacity(0.1))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -142,7 +139,7 @@ struct CreditsView: View {
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(.accentColor)
|
||||||
.padding(10)
|
.padding(10)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(Color.secondary.opacity(reduceTransparency ? 0.4 : 0.1))
|
.background(Color.secondary.opacity(0.1))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -174,7 +171,7 @@ struct CreditsView: View {
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(.accentColor)
|
||||||
.padding(10)
|
.padding(10)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(Color.secondary.opacity(reduceTransparency ? 0.4 : 0.1))
|
.background(Color.secondary.opacity(0.1))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -202,7 +199,7 @@ struct CreditsView: View {
|
||||||
}
|
}
|
||||||
.padding(10)
|
.padding(10)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(Color.secondary.opacity(reduceTransparency ? 0.4 : 0.1))
|
.background(Color.secondary.opacity(0.1))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -240,21 +237,21 @@ struct CreditsView: View {
|
||||||
private func openTornProfile(_ tornID: Int) {
|
private func openTornProfile(_ tornID: Int) {
|
||||||
let urlString = "https://www.torn.com/profiles.php?XID=\(tornID)"
|
let urlString = "https://www.torn.com/profiles.php?XID=\(tornID)"
|
||||||
if let url = URL(string: urlString) {
|
if let url = URL(string: urlString) {
|
||||||
BrowserManager.shared.open(url)
|
NSWorkspace.shared.open(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func openFaction(_ factionID: Int) {
|
private func openFaction(_ factionID: Int) {
|
||||||
let urlString = "https://www.torn.com/factions.php?step=profile&ID=\(factionID)"
|
let urlString = "https://www.torn.com/factions.php?step=profile&ID=\(factionID)"
|
||||||
if let url = URL(string: urlString) {
|
if let url = URL(string: urlString) {
|
||||||
BrowserManager.shared.open(url)
|
NSWorkspace.shared.open(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func openCompany(_ ownerID: Int) {
|
private func openCompany(_ ownerID: Int) {
|
||||||
let urlString = "https://www.torn.com/joblist.php#/p=corpinfo&userID=\(ownerID)"
|
let urlString = "https://www.torn.com/joblist.php#/p=corpinfo&userID=\(ownerID)"
|
||||||
if let url = URL(string: urlString) {
|
if let url = URL(string: urlString) {
|
||||||
BrowserManager.shared.open(url)
|
NSWorkspace.shared.open(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import SwiftUI
|
||||||
|
|
||||||
struct FactionView: View {
|
struct FactionView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@Environment(\.reduceTransparency) private var reduceTransparency
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
|
|
@ -34,7 +33,7 @@ struct FactionView: View {
|
||||||
.foregroundColor(chainColor(faction.chain))
|
.foregroundColor(chainColor(faction.chain))
|
||||||
}
|
}
|
||||||
.padding(8)
|
.padding(8)
|
||||||
.background(chainColor(faction.chain).opacity(reduceTransparency ? 0.4 : 0.1))
|
.background(chainColor(faction.chain).opacity(0.1))
|
||||||
.cornerRadius(6)
|
.cornerRadius(6)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,7 +58,7 @@ struct FactionView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color.blue.opacity(reduceTransparency ? 0.25 : 0.05))
|
.background(Color.blue.opacity(0.05))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
|
|
||||||
// Armory Quick Actions
|
// Armory Quick Actions
|
||||||
|
|
@ -86,7 +85,7 @@ struct FactionView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color.purple.opacity(reduceTransparency ? 0.25 : 0.05))
|
.background(Color.purple.opacity(0.05))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
|
@ -132,14 +131,13 @@ struct FactionView: View {
|
||||||
|
|
||||||
private func openURL(_ urlString: String) {
|
private func openURL(_ urlString: String) {
|
||||||
if let url = URL(string: urlString) {
|
if let url = URL(string: urlString) {
|
||||||
BrowserManager.shared.open(url)
|
NSWorkspace.shared.open(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Armory Button
|
// MARK: - Armory Button
|
||||||
struct ArmoryButton: View {
|
struct ArmoryButton: View {
|
||||||
@Environment(\.reduceTransparency) private var reduceTransparency
|
|
||||||
let title: String
|
let title: String
|
||||||
let icon: String
|
let icon: String
|
||||||
let color: Color
|
let color: Color
|
||||||
|
|
@ -155,7 +153,7 @@ struct ArmoryButton: View {
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(color.opacity(reduceTransparency ? 0.4 : 0.15))
|
.background(color.opacity(0.15))
|
||||||
.foregroundColor(color)
|
.foregroundColor(color)
|
||||||
.cornerRadius(6)
|
.cornerRadius(6)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import SwiftUI
|
||||||
|
|
||||||
struct MoneyView: View {
|
struct MoneyView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@Environment(\.reduceTransparency) private var reduceTransparency
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
|
|
@ -73,7 +72,7 @@ struct MoneyView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color.green.opacity(reduceTransparency ? 0.25 : 0.05))
|
.background(Color.green.opacity(0.05))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
|
@ -106,14 +105,13 @@ struct MoneyView: View {
|
||||||
|
|
||||||
private func openURL(_ urlString: String) {
|
private func openURL(_ urlString: String) {
|
||||||
if let url = URL(string: urlString) {
|
if let url = URL(string: urlString) {
|
||||||
BrowserManager.shared.open(url)
|
NSWorkspace.shared.open(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Action Button Component
|
// MARK: - Action Button Component
|
||||||
struct ActionButton: View {
|
struct ActionButton: View {
|
||||||
@Environment(\.reduceTransparency) private var reduceTransparency
|
|
||||||
let title: String
|
let title: String
|
||||||
let icon: String
|
let icon: String
|
||||||
let color: Color
|
let color: Color
|
||||||
|
|
@ -129,7 +127,7 @@ struct ActionButton: View {
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.background(color.opacity(reduceTransparency ? 0.4 : 0.1))
|
.background(color.opacity(0.1))
|
||||||
.foregroundColor(color)
|
.foregroundColor(color)
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import SwiftUI
|
||||||
|
|
||||||
struct PropertiesView: View {
|
struct PropertiesView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@Environment(\.reduceTransparency) private var reduceTransparency
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
|
|
@ -52,14 +51,13 @@ struct PropertiesView: View {
|
||||||
|
|
||||||
private func openURL(_ urlString: String) {
|
private func openURL(_ urlString: String) {
|
||||||
if let url = URL(string: urlString) {
|
if let url = URL(string: urlString) {
|
||||||
BrowserManager.shared.open(url)
|
NSWorkspace.shared.open(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Property Card
|
// MARK: - Property Card
|
||||||
struct PropertyCard: View {
|
struct PropertyCard: View {
|
||||||
@Environment(\.reduceTransparency) private var reduceTransparency
|
|
||||||
let property: PropertyInfo
|
let property: PropertyInfo
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|
@ -74,7 +72,7 @@ struct PropertyCard: View {
|
||||||
.foregroundColor(.orange)
|
.foregroundColor(.orange)
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
.background(Color.orange.opacity(reduceTransparency ? 0.5 : 0.2))
|
.background(Color.orange.opacity(0.2))
|
||||||
.cornerRadius(4)
|
.cornerRadius(4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -114,7 +112,7 @@ struct PropertyCard: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color.brown.opacity(reduceTransparency ? 0.25 : 0.05))
|
.background(Color.brown.opacity(0.05))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,11 @@ import SwiftUI
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@AppStorage("appearanceMode") private var appearanceMode: String = AppearanceMode.system.rawValue
|
|
||||||
@AppStorage("reduceTransparency") private var reduceTransparency: Bool = false
|
|
||||||
@AppStorage("preferredBrowser") private var preferredBrowser: String = PreferredBrowser.system.rawValue
|
|
||||||
@State private var inputKey: String = ""
|
@State private var inputKey: String = ""
|
||||||
@State private var showCredits: Bool = false
|
@State private var showCredits: Bool = false
|
||||||
@State private var availableBrowsers: [PreferredBrowser] = PreferredBrowser.availableBrowsers()
|
|
||||||
|
|
||||||
private let developerID = TornConstants.developerID
|
// Developer ID for tip feature (bombel)
|
||||||
|
private let developerID = 2362436
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if showCredits {
|
if showCredits {
|
||||||
|
|
@ -73,7 +70,7 @@ struct SettingsView: View {
|
||||||
Text("2m").tag(120)
|
Text("2m").tag(120)
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
.onChange(of: appState.refreshInterval) {
|
.onChange(of: appState.refreshInterval) { _ in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
appState.startPolling()
|
appState.startPolling()
|
||||||
}
|
}
|
||||||
|
|
@ -91,44 +88,6 @@ struct SettingsView: View {
|
||||||
))
|
))
|
||||||
.toggleStyle(.switch)
|
.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()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preferred Browser
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "globe")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.frame(width: 20)
|
|
||||||
|
|
||||||
Picker("Preferred Browser", selection: $preferredBrowser) {
|
|
||||||
ForEach(availableBrowsers) { browser in
|
|
||||||
Text(browser.rawValue).tag(browser.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pickerStyle(.menu)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reduce Transparency (Accessibility)
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "eye")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.frame(width: 20)
|
|
||||||
Toggle("Reduce Transparency", isOn: $reduceTransparency)
|
|
||||||
.toggleStyle(.switch)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
|
@ -157,14 +116,14 @@ struct SettingsView: View {
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.background(Color.purple.opacity(reduceTransparency ? 0.4 : 0.15))
|
.background(Color.purple.opacity(0.15))
|
||||||
.cornerRadius(6)
|
.cornerRadius(6)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.background(Color.purple.opacity(reduceTransparency ? 0.25 : 0.05))
|
.background(Color.purple.opacity(0.05))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
|
|
||||||
// Update Section
|
// Update Section
|
||||||
|
|
@ -179,7 +138,7 @@ struct SettingsView: View {
|
||||||
|
|
||||||
Button("Download Update") {
|
Button("Download Update") {
|
||||||
if let url = URL(string: update.htmlUrl) {
|
if let url = URL(string: update.htmlUrl) {
|
||||||
BrowserManager.shared.open(url)
|
NSWorkspace.shared.open(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
|
|
@ -187,7 +146,7 @@ struct SettingsView: View {
|
||||||
}
|
}
|
||||||
.padding(10)
|
.padding(10)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(Color.green.opacity(reduceTransparency ? 0.4 : 0.1))
|
.background(Color.green.opacity(0.1))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -230,15 +189,6 @@ struct SettingsView: View {
|
||||||
.frame(width: 320)
|
.frame(width: 320)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
inputKey = appState.apiKey
|
inputKey = appState.apiKey
|
||||||
refreshAvailableBrowsers()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func refreshAvailableBrowsers() {
|
|
||||||
let browsers = PreferredBrowser.availableBrowsers()
|
|
||||||
availableBrowsers = browsers
|
|
||||||
if !browsers.contains(where: { $0.rawValue == preferredBrowser }) {
|
|
||||||
preferredBrowser = PreferredBrowser.system.rawValue
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -292,7 +242,7 @@ struct SettingsView: View {
|
||||||
private func openTornProfile() {
|
private func openTornProfile() {
|
||||||
let url = "https://www.torn.com/profiles.php?XID=\(developerID)"
|
let url = "https://www.torn.com/profiles.php?XID=\(developerID)"
|
||||||
if let url = URL(string: url) {
|
if let url = URL(string: url) {
|
||||||
BrowserManager.shared.open(url)
|
NSWorkspace.shared.open(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import SwiftUI
|
||||||
|
|
||||||
struct StatusView: View {
|
struct StatusView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@Environment(\.reduceTransparency) private var reduceTransparency
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
|
|
@ -99,7 +98,7 @@ struct StatusView: View {
|
||||||
private var messagesBadge: some View {
|
private var messagesBadge: some View {
|
||||||
Button {
|
Button {
|
||||||
if let url = URL(string: "https://www.torn.com/messages.php") {
|
if let url = URL(string: "https://www.torn.com/messages.php") {
|
||||||
BrowserManager.shared.open(url)
|
NSWorkspace.shared.open(url)
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
|
|
@ -110,7 +109,7 @@ struct StatusView: View {
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(Color.blue.opacity(reduceTransparency ? 0.2 : 0.1))
|
.background(Color.blue.opacity(0.1))
|
||||||
.cornerRadius(6)
|
.cornerRadius(6)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|
@ -151,7 +150,7 @@ struct StatusView: View {
|
||||||
}
|
}
|
||||||
.padding(8)
|
.padding(8)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(Color.blue.opacity(reduceTransparency ? 0.2 : 0.1))
|
.background(Color.blue.opacity(0.1))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
.transaction { $0.animation = nil }
|
.transaction { $0.animation = nil }
|
||||||
}
|
}
|
||||||
|
|
@ -241,7 +240,7 @@ struct StatusView: View {
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
.background(Color.accentColor.opacity(reduceTransparency ? 0.2 : 0.1))
|
.background(Color.accentColor.opacity(0.1))
|
||||||
.cornerRadius(4)
|
.cornerRadius(4)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|
@ -271,7 +270,7 @@ struct CooldownItem: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 2) {
|
VStack(spacing: 2) {
|
||||||
Text(label)
|
Image(systemName: icon)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(seconds > 0 ? .orange : .green)
|
.foregroundColor(seconds > 0 ? .orange : .green)
|
||||||
|
|
||||||
|
|
@ -308,7 +307,7 @@ struct LiveCooldownItem: View {
|
||||||
let remaining = max(0, originalSeconds - elapsed)
|
let remaining = max(0, originalSeconds - elapsed)
|
||||||
|
|
||||||
VStack(spacing: 2) {
|
VStack(spacing: 2) {
|
||||||
Text(label)
|
Image(systemName: icon)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(remaining > 0 ? .orange : .green)
|
.foregroundColor(remaining > 0 ? .orange : .green)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import AppKit
|
||||||
// MARK: - Flying Status View (separate for proper live updates)
|
// MARK: - Flying Status View (separate for proper live updates)
|
||||||
struct FlyingStatusView: View {
|
struct FlyingStatusView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@Environment(\.reduceTransparency) private var reduceTransparency
|
|
||||||
let destination: String
|
let destination: String
|
||||||
let timestamp: Int
|
let timestamp: Int
|
||||||
let departed: Int
|
let departed: Int
|
||||||
|
|
@ -61,7 +60,7 @@ struct FlyingStatusView: View {
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
RoundedRectangle(cornerRadius: 4)
|
RoundedRectangle(cornerRadius: 4)
|
||||||
.fill(Color.gray.opacity(reduceTransparency ? 0.5 : 0.2))
|
.fill(Color.gray.opacity(0.2))
|
||||||
.frame(height: 8)
|
.frame(height: 8)
|
||||||
|
|
||||||
RoundedRectangle(cornerRadius: 4)
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
|
@ -73,7 +72,7 @@ struct FlyingStatusView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color.blue.opacity(reduceTransparency ? 0.2 : 0.1))
|
.background(Color.blue.opacity(0.1))
|
||||||
.cornerRadius(12)
|
.cornerRadius(12)
|
||||||
.transaction { $0.animation = nil }
|
.transaction { $0.animation = nil }
|
||||||
}
|
}
|
||||||
|
|
@ -81,7 +80,6 @@ struct FlyingStatusView: View {
|
||||||
|
|
||||||
struct TravelView: View {
|
struct TravelView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@Environment(\.reduceTransparency) private var reduceTransparency
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
|
|
@ -149,7 +147,7 @@ struct TravelView: View {
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
if let url = URL(string: "https://www.torn.com/travelagency.php") {
|
if let url = URL(string: "https://www.torn.com/travelagency.php") {
|
||||||
BrowserManager.shared.open(url)
|
NSWorkspace.shared.open(url)
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
|
|
@ -166,7 +164,7 @@ struct TravelView: View {
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color.orange.opacity(reduceTransparency ? 0.2 : 0.1))
|
.background(Color.orange.opacity(0.1))
|
||||||
.cornerRadius(12)
|
.cornerRadius(12)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -185,7 +183,7 @@ struct TravelView: View {
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color.green.opacity(reduceTransparency ? 0.2 : 0.1))
|
.background(Color.green.opacity(0.1))
|
||||||
.cornerRadius(12)
|
.cornerRadius(12)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -204,7 +202,7 @@ struct TravelView: View {
|
||||||
// Show only return button when abroad
|
// Show only return button when abroad
|
||||||
Button {
|
Button {
|
||||||
if let url = URL(string: "https://www.torn.com/travelagency.php") {
|
if let url = URL(string: "https://www.torn.com/travelagency.php") {
|
||||||
BrowserManager.shared.open(url)
|
NSWorkspace.shared.open(url)
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
|
|
@ -214,7 +212,7 @@ struct TravelView: View {
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
.background(Color.accentColor.opacity(reduceTransparency ? 0.2 : 0.1))
|
.background(Color.accentColor.opacity(0.1))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|
@ -234,7 +232,7 @@ struct TravelView: View {
|
||||||
|
|
||||||
private func destinationButton(_ destination: TornDestination) -> some View {
|
private func destinationButton(_ destination: TornDestination) -> some View {
|
||||||
Button {
|
Button {
|
||||||
BrowserManager.shared.open(destination.travelAgencyURL)
|
NSWorkspace.shared.open(destination.travelAgencyURL)
|
||||||
} label: {
|
} label: {
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: 4) {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
|
|
@ -250,7 +248,7 @@ struct TravelView: View {
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.background(Color.accentColor.opacity(reduceTransparency ? 0.2 : 0.1))
|
.background(Color.accentColor.opacity(0.1))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|
@ -285,7 +283,7 @@ struct TravelView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color.secondary.opacity(reduceTransparency ? 0.2 : 0.1))
|
.background(Color.secondary.opacity(0.1))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -304,7 +302,7 @@ struct TravelView: View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Button {
|
Button {
|
||||||
if let url = URL(string: "https://www.torn.com/travelagency.php") {
|
if let url = URL(string: "https://www.torn.com/travelagency.php") {
|
||||||
BrowserManager.shared.open(url)
|
NSWorkspace.shared.open(url)
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
|
|
@ -314,14 +312,14 @@ struct TravelView: View {
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.background(Color.accentColor.opacity(reduceTransparency ? 0.2 : 0.1))
|
.background(Color.accentColor.opacity(0.1))
|
||||||
.cornerRadius(6)
|
.cornerRadius(6)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
if let url = URL(string: "https://www.torn.com/page.php?sid=ItemMarket") {
|
if let url = URL(string: "https://www.torn.com/page.php?sid=ItemMarket") {
|
||||||
BrowserManager.shared.open(url)
|
NSWorkspace.shared.open(url)
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
|
|
@ -331,7 +329,7 @@ struct TravelView: View {
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.background(Color.accentColor.opacity(reduceTransparency ? 0.2 : 0.1))
|
.background(Color.accentColor.opacity(0.1))
|
||||||
.cornerRadius(6)
|
.cornerRadius(6)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import SwiftUI
|
||||||
|
|
||||||
struct WatchlistView: View {
|
struct WatchlistView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@Environment(\.reduceTransparency) private var reduceTransparency
|
|
||||||
@State private var showAddItem = false
|
@State private var showAddItem = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|
@ -57,7 +56,7 @@ struct WatchlistView: View {
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(Color.green.opacity(reduceTransparency ? 0.4 : 0.1))
|
.background(Color.green.opacity(0.1))
|
||||||
.cornerRadius(4)
|
.cornerRadius(4)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|
@ -65,7 +64,7 @@ struct WatchlistView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(8)
|
.padding(8)
|
||||||
.background(Color.gray.opacity(reduceTransparency ? 0.4 : 0.1))
|
.background(Color.gray.opacity(0.1))
|
||||||
.cornerRadius(6)
|
.cornerRadius(6)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,14 +125,13 @@ struct WatchlistView: View {
|
||||||
|
|
||||||
private func openURL(_ urlString: String) {
|
private func openURL(_ urlString: String) {
|
||||||
if let url = URL(string: urlString) {
|
if let url = URL(string: urlString) {
|
||||||
BrowserManager.shared.open(url)
|
NSWorkspace.shared.open(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Watchlist Price Row
|
// MARK: - Watchlist Price Row
|
||||||
struct WatchlistPriceRow: View {
|
struct WatchlistPriceRow: View {
|
||||||
@Environment(\.reduceTransparency) private var reduceTransparency
|
|
||||||
let item: WatchlistItem
|
let item: WatchlistItem
|
||||||
let onOpen: () -> Void
|
let onOpen: () -> Void
|
||||||
let onRemove: () -> Void
|
let onRemove: () -> Void
|
||||||
|
|
@ -202,7 +200,7 @@ struct WatchlistPriceRow: View {
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
.padding(8)
|
.padding(8)
|
||||||
.background(Color.gray.opacity(reduceTransparency ? 0.4 : 0.1))
|
.background(Color.gray.opacity(0.1))
|
||||||
.cornerRadius(6)
|
.cornerRadius(6)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -132,108 +132,4 @@ final class TravelTests: XCTestCase {
|
||||||
let travel = try decode(Travel.self, from: json)
|
let travel = try decode(Travel.self, from: json)
|
||||||
XCTAssertFalse(travel.isTraveling)
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,186 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -136,8 +136,8 @@ final class MacTornUITests: XCTestCase {
|
||||||
// MARK: - UI Test Helpers
|
// MARK: - UI Test Helpers
|
||||||
|
|
||||||
extension XCUIElement {
|
extension XCUIElement {
|
||||||
/// Wait for element to appear within the given timeout
|
/// Wait for element to exist with timeout
|
||||||
func waitForAppearance(timeout: TimeInterval = 5) -> Bool {
|
func waitForExistence(timeout: TimeInterval = 5) -> Bool {
|
||||||
return self.waitForExistence(timeout: timeout)
|
return self.waitForExistence(timeout: timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
6
Makefile
6
Makefile
|
|
@ -57,15 +57,13 @@ build:
|
||||||
CODE_SIGN_IDENTITY="-" \
|
CODE_SIGN_IDENTITY="-" \
|
||||||
CODE_SIGNING_REQUIRED=NO
|
CODE_SIGNING_REQUIRED=NO
|
||||||
|
|
||||||
# Build Release (Universal Binary for Intel + Apple Silicon)
|
# Build Release
|
||||||
release:
|
release:
|
||||||
xcodebuild build \
|
xcodebuild build \
|
||||||
-project MacTorn/MacTorn.xcodeproj \
|
-project MacTorn/MacTorn.xcodeproj \
|
||||||
-scheme MacTorn \
|
-scheme MacTorn \
|
||||||
-configuration Release \
|
-configuration Release \
|
||||||
-destination 'generic/platform=macOS' \
|
-destination 'platform=macOS' \
|
||||||
ARCHS="arm64 x86_64" \
|
|
||||||
ONLY_ACTIVE_ARCH=NO \
|
|
||||||
CODE_SIGN_IDENTITY="-" \
|
CODE_SIGN_IDENTITY="-" \
|
||||||
CODE_SIGNING_REQUIRED=NO
|
CODE_SIGNING_REQUIRED=NO
|
||||||
|
|
||||||
|
|
|
||||||
21
README.md
21
README.md
|
|
@ -4,20 +4,12 @@ A native macOS menu bar app for monitoring your **Torn** game status.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|
|
||||||

|

|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="app_light_1.png" alt="MacTorn Light Mode" width="320">
|
<img src="app.png?v=1.2" alt="MacTorn Screenshot" width="600">
|
||||||
|
|
||||||
<img src="app_dark_1.png" alt="MacTorn Dark Mode" width="320">
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
For detailed documentation, visit the [MacTorn Wiki](https://github.com/pawelorzech/MacTorn/wiki).
|
|
||||||
For community discussion and feedback, see the [Torn forums thread](https://www.torn.com/forums.php#/p=threads&f=67&t=16532308).
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### 📊 Status Tab
|
### 📊 Status Tab
|
||||||
|
|
@ -28,7 +20,7 @@ For community discussion and feedback, see the [Torn forums thread](https://www.
|
||||||
- Hospital/Jail status badges
|
- Hospital/Jail status badges
|
||||||
- Unread messages badge
|
- Unread messages badge
|
||||||
- Events feed
|
- Events feed
|
||||||
- 8 quick links
|
- 8 customizable quick links
|
||||||
|
|
||||||
### ✈️ Travel Tab
|
### ✈️ Travel Tab
|
||||||
- **Live countdown timer** in menu bar during flight (✈️🇺🇸 5:32)
|
- **Live countdown timer** in menu bar during flight (✈️🇺🇸 5:32)
|
||||||
|
|
@ -64,14 +56,6 @@ For community discussion and feedback, see the [Torn forums thread](https://www.
|
||||||
- **🚀 Launch at Login**: Start seamlessly with macOS.
|
- **🚀 Launch at Login**: Start seamlessly with macOS.
|
||||||
- **⚡️ Optimized Startup**: Non-blocking data fetching for instant UI responsiveness.
|
- **⚡️ 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
|
## Installation
|
||||||
|
|
||||||
1. Download the latest release from [Releases](https://github.com/pawelorzech/MacTorn/releases)
|
1. Download the latest release from [Releases](https://github.com/pawelorzech/MacTorn/releases)
|
||||||
|
|
@ -84,7 +68,6 @@ MacTorn respects macOS accessibility settings:
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- macOS 13.0 (Ventura) or later
|
- 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
|
- Torn API Key with access to: basic, bars, cooldowns, travel, profile, events, messages, market
|
||||||
|
|
||||||
## API Data Usage
|
## API Data Usage
|
||||||
|
|
|
||||||
BIN
app.png
Normal file
BIN
app.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 246 KiB |
BIN
app_dark_1.png
BIN
app_dark_1.png
Binary file not shown.
|
Before Width: | Height: | Size: 233 KiB |
BIN
app_light_1.png
BIN
app_light_1.png
Binary file not shown.
|
Before Width: | Height: | Size: 226 KiB |
Loading…
Reference in a new issue