mirror of
https://github.com/otaviocc/Triton.git
synced 2026-01-30 04:04:27 +00:00
Compare commits
12 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59f79e55c5 | ||
|
|
46fd069259 | ||
|
|
d4aecbe8ed | ||
|
|
3ed032e8df | ||
|
|
afdd145a63 | ||
|
|
ee174ab836 | ||
|
|
d3b596c26f | ||
|
|
1a105917b2 | ||
|
|
2de32c7160 | ||
|
|
b9e3362d1d | ||
|
|
eaef589399 | ||
|
|
4d95a7d40f |
77 changed files with 786 additions and 271 deletions
45
.github/workflows/build.yml
vendored
Normal file
45
.github/workflows/build.yml
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
name: Build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build OMG
|
||||
runs-on: macos-26
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Select Xcode version
|
||||
uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: '26.1.1'
|
||||
|
||||
- name: Show Xcode version
|
||||
run: xcodebuild -version
|
||||
|
||||
- name: Cache Swift Package Manager
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
.build
|
||||
~/Library/Developer/Xcode/DerivedData
|
||||
key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-spm-
|
||||
|
||||
- name: Resolve Swift Package dependencies
|
||||
run: xcodebuild -resolvePackageDependencies -project OMG.xcodeproj -scheme OMG
|
||||
|
||||
- name: Build OMG
|
||||
run: |
|
||||
xcodebuild build \
|
||||
-project OMG.xcodeproj \
|
||||
-scheme OMG \
|
||||
-configuration Debug \
|
||||
-destination 'platform=macOS' \
|
||||
| xcpretty || exit 1
|
||||
continue-on-error: false
|
||||
21
.github/workflows/draft-release.yml
vendored
21
.github/workflows/draft-release.yml
vendored
|
|
@ -91,6 +91,27 @@ jobs:
|
|||
echo "No pull requests were merged in this release."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "## Installation"
|
||||
echo ""
|
||||
echo "**Download directly from GitHub Releases**"
|
||||
echo ""
|
||||
echo "Download the \`.zip\` file from this release, extract it, and drag OMG.app to your Applications folder."
|
||||
echo ""
|
||||
echo "**Install via [Homebrew](https://brew.sh)**"
|
||||
echo ""
|
||||
echo "\`\`\`bash"
|
||||
echo "brew tap otaviocc/apps"
|
||||
echo "brew install --cask triton"
|
||||
echo "\`\`\`"
|
||||
echo ""
|
||||
echo "**Upgrade via Homebrew**"
|
||||
echo ""
|
||||
echo "If you installed Triton using Homebrew, upgrade to the latest version with:"
|
||||
echo ""
|
||||
echo "\`\`\`bash"
|
||||
echo "brew upgrade --cask triton"
|
||||
echo "\`\`\`"
|
||||
} > release_notes.md
|
||||
|
||||
cat release_notes.md
|
||||
|
|
|
|||
20
.github/workflows/swiftformat.yml
vendored
Normal file
20
.github/workflows/swiftformat.yml
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
name: SwiftFormat Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
swiftformat:
|
||||
name: Code Formatting Check
|
||||
runs-on: macos-26
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install swiftformat
|
||||
run: brew install swiftformat
|
||||
|
||||
- name: Check code formatting
|
||||
run: swiftformat --lint .
|
||||
20
.github/workflows/swiftlint.yml
vendored
Normal file
20
.github/workflows/swiftlint.yml
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
name: SwiftLint Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
swiftlint:
|
||||
name: SwiftLint
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install SwiftLint
|
||||
run: brew install swiftlint
|
||||
|
||||
- name: Run SwiftLint
|
||||
run: swiftlint lint --reporter github-actions-logging
|
||||
|
|
@ -1,14 +1,12 @@
|
|||
name: Pull Request Checks
|
||||
name: Unit Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build-and-format:
|
||||
name: Build and Format Check
|
||||
test:
|
||||
name: Run Unit Tests
|
||||
runs-on: macos-26
|
||||
|
||||
steps:
|
||||
|
|
@ -23,17 +21,6 @@ jobs:
|
|||
- name: Show Xcode version
|
||||
run: xcodebuild -version
|
||||
|
||||
- name: Install swiftformat
|
||||
run: brew install swiftformat
|
||||
|
||||
- name: Check code formatting
|
||||
run: |
|
||||
swiftformat --lint .
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ Code formatting issues detected. Please run 'swiftformat .' locally to fix."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Cache Swift Package Manager
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
|
|
@ -47,16 +34,6 @@ jobs:
|
|||
- name: Resolve Swift Package dependencies
|
||||
run: xcodebuild -resolvePackageDependencies -project OMG.xcodeproj -scheme OMG
|
||||
|
||||
- name: Build OMG
|
||||
run: |
|
||||
xcodebuild build \
|
||||
-project OMG.xcodeproj \
|
||||
-scheme OMG \
|
||||
-configuration Debug \
|
||||
-destination 'platform=macOS' \
|
||||
| xcpretty || exit 1
|
||||
continue-on-error: false
|
||||
|
||||
- name: Run OMG Tests
|
||||
run: |
|
||||
xcodebuild test \
|
||||
|
|
@ -50,7 +50,7 @@
|
|||
--ifdef indent
|
||||
|
||||
# MARK: - SwiftUI and Modern Swift Features
|
||||
--swift-version 6.0
|
||||
--swift-version 6.2
|
||||
--sort-swiftui-properties first-appearance-sort
|
||||
--some-any true
|
||||
--short-optionals always
|
||||
|
|
|
|||
64
.swiftlint.yml
Normal file
64
.swiftlint.yml
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# Minimal SwiftLint configuration
|
||||
# SwiftFormat handles all formatting, so SwiftLint focuses on code quality and best practices
|
||||
|
||||
disabled_rules:
|
||||
- line_length
|
||||
- opening_brace
|
||||
- function_parameter_count
|
||||
- nesting
|
||||
- large_tuple
|
||||
- type_name
|
||||
- force_unwrapping
|
||||
- blanket_disable_command
|
||||
|
||||
opt_in_rules:
|
||||
- array_init
|
||||
- contains_over_first_not_nil
|
||||
- convenience_type
|
||||
- discouraged_assert
|
||||
- discouraged_object_literal
|
||||
- empty_count
|
||||
- empty_string
|
||||
- empty_xctest_method
|
||||
- explicit_init
|
||||
- fatal_error_message
|
||||
- first_where
|
||||
- force_cast
|
||||
- force_try
|
||||
- implicit_return
|
||||
- joined_default_parameter
|
||||
- last_where
|
||||
- legacy_random
|
||||
- lower_acl_than_parent
|
||||
- multiline_function_chains
|
||||
- multiline_parameters
|
||||
- multiline_parameters_brackets
|
||||
- no_fallthrough_only
|
||||
- operator_usage_whitespace
|
||||
- overridden_super_call
|
||||
- prohibited_super_call
|
||||
- redundant_nil_coalescing
|
||||
- single_test_class
|
||||
- sorted_first_last
|
||||
- static_operator
|
||||
- switch_case_alignment
|
||||
- trailing_closure
|
||||
- unavailable_function
|
||||
- unneeded_parentheses_in_closure_argument
|
||||
- unused_control_flow_label
|
||||
- vertical_whitespace_closing_braces
|
||||
- xct_specific_matcher
|
||||
- xctfail_message
|
||||
- yoda_condition
|
||||
|
||||
multiline_parameters:
|
||||
allows_single_line: false
|
||||
|
||||
excluded:
|
||||
- .build
|
||||
- "**/.build"
|
||||
- DerivedData
|
||||
- Pods
|
||||
- Carthage
|
||||
- .git
|
||||
- node_modules
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
# ADR-017: Direct Distribution Outside App Store
|
||||
|
||||
**Status:** Accepted
|
||||
|
||||
**Date:** 2025-01-22
|
||||
|
||||
**Context:**
|
||||
|
||||
When deciding how to distribute the macOS application, I evaluated two primary distribution channels:
|
||||
|
||||
1. **Mac App Store:** Apple's official marketplace with built-in distribution and discovery
|
||||
2. **Direct distribution:** Downloadable DMG/PKG outside the App Store (sideloading)
|
||||
|
||||
Key considerations influencing this decision:
|
||||
|
||||
- **Development velocity:** Ability to iterate and release updates quickly
|
||||
- **Review process overhead:** Time and friction introduced by App Store review cycles
|
||||
- **Cost implications:** Additional expenses beyond existing Apple Developer Program membership
|
||||
- **Security and trust:** Maintaining user confidence through proper code signing and notarization
|
||||
- **Control over distribution:** Flexibility in release timing and update deployment
|
||||
|
||||
The application requires an omg.lol account for authentication and core functionality. Apple's App Store review process requires providing reviewers with a functional test account, which would necessitate purchasing an additional omg.lol address specifically for review purposes.
|
||||
|
||||
**Decision:**
|
||||
|
||||
I chose to distribute the application directly outside the Mac App Store through downloadable DMG or PKG installers. Users download and install the application manually (sideloading), bypassing the App Store entirely.
|
||||
|
||||
**Distribution Approach:**
|
||||
|
||||
1. **Direct downloads:** Hosted files available from GitHub releases
|
||||
2. **Code signing:** Application binary signed with Apple Developer ID certificate
|
||||
3. **Notarization:** Binary submitted to Apple's notarization service for malware scanning
|
||||
4. **Gatekeeper compliance:** Notarized app passes macOS Gatekeeper checks on first launch
|
||||
5. **Manual updates:** Users download and install updates manually
|
||||
|
||||
**Security Measures (Still Applied):**
|
||||
|
||||
Despite not being distributed through the App Store, the application maintains the same security practices:
|
||||
|
||||
- **Code signing required:** Binary must be signed with valid Developer ID
|
||||
- **Notarization required:** Apple's automated security scan must pass before distribution
|
||||
- **Gatekeeper verification:** macOS verifies signature and notarization on first launch
|
||||
- **Static analysis:** Binary undergoes Apple's automated malware and security checks
|
||||
- **Developer accountability:** Apple Developer ID ties the application to verified developer account
|
||||
|
||||
The only difference from App Store distribution is the absence of manual human review. All automated security checks, signing requirements, and notarization processes remain identical.
|
||||
|
||||
**Consequences:**
|
||||
|
||||
### Positive
|
||||
|
||||
- **Faster iteration cycles:** No waiting for App Store review approval (typically 24-48 hours per submission)
|
||||
- **Immediate releases:** Updates can be deployed as soon as they're ready
|
||||
- **No review friction:** Avoid potential rejections requiring code changes and resubmission
|
||||
- **Cost savings:** No additional omg.lol subscription required for App Store review account ($20/year saved)
|
||||
- **Developer Program only:** Single $99/year Apple Developer membership covers all requirements
|
||||
- **Release control:** Full control over timing, rollback, and phased rollouts
|
||||
- **No guideline restrictions:** Freedom from App Store Review Guidelines constraints (beyond security requirements)
|
||||
- **Testing flexibility:** No need to maintain separate review-specific test accounts
|
||||
|
||||
### Negative
|
||||
|
||||
- **No App Store discovery:** Users cannot find the app through Mac App Store search
|
||||
- **Manual update flow:** Users must manually check for and install updates (unless in-app updater implemented)
|
||||
- **Trust barrier:** Some users hesitate to install applications outside the App Store
|
||||
- **Distribution responsibility:** Must host files on reliable infrastructure (GitHub releases)
|
||||
- **No App Store features:** Cannot leverage TestFlight, automatic updates, or App Store metadata/screenshots for marketing
|
||||
|
||||
### Neutral
|
||||
|
||||
- **Notarization turnaround:** Apple notarization still required but typically completes within minutes (faster than full review)
|
||||
- **Gatekeeper warnings:** First launch shows standard "downloaded from internet" warning (expected for all non-App Store apps)
|
||||
- **Marketing channels:** Must rely on direct marketing, social media, and community rather than App Store presence
|
||||
|
||||
**Why This Doesn't Compromise Quality:**
|
||||
|
||||
Direct distribution does not mean lower security or quality standards. The application still undergoes:
|
||||
|
||||
1. **Developer ID signing:** Cryptographic signature proving developer identity
|
||||
2. **Notarization:** Apple's automated security analysis scanning for malware and policy violations
|
||||
3. **Gatekeeper checks:** macOS verifies the app's signature and notarization before first launch
|
||||
4. **Same binary standards:** Hardened runtime, code signing requirements identical to App Store builds
|
||||
|
||||
The primary difference is the absence of manual human review, not the absence of security checks. App Store review primarily enforces guideline compliance (UI/UX standards, business model rules, content policies) rather than discovering security issues that automated tools miss.
|
||||
|
||||
**Cost-Benefit Analysis:**
|
||||
|
||||
- **Current cost:** $99/year Apple Developer Program (required for notarization and signing)
|
||||
- **App Store additional cost:** $20/year omg.lol test account (20% overhead)
|
||||
- **App Store time cost:** 24-48 hours per release (blocks urgent fixes)
|
||||
- **Development velocity value:** Immediate releases enable faster user feedback and bug fixes
|
||||
|
||||
For a single-developer project with an existing account requirement, avoiding the App Store review process provides better ROI through faster iteration and lower operational overhead.
|
||||
|
||||
**Related Decisions:**
|
||||
|
||||
- Future consideration: Could revisit App Store distribution if user acquisition through App Store becomes strategically valuable
|
||||
- Potential automation: Could implement Sparkle framework or similar for automated update checks
|
||||
|
||||
**Notes:**
|
||||
|
||||
This decision prioritizes development velocity and cost efficiency while maintaining identical security standards through code signing and notarization. Direct distribution is a common and legitimate approach for many professional macOS applications (Homebrew, VS Code, Docker Desktop, etc.).
|
||||
|
|
@ -76,6 +76,11 @@ Standardized context menu structure for content items using native ShareLink for
|
|||
### [ADR-016: SwiftUI Previews with Mother Objects](ADR-016-swiftui-previews-with-mother-objects.md)
|
||||
Use of Mother Object pattern for creating reusable test fixtures that support SwiftUI Previews across 90% of views, enabling rapid UI development with realistic data.
|
||||
|
||||
## Distribution
|
||||
|
||||
### [ADR-017: Direct Distribution Outside App Store](ADR-017-direct-distribution-outside-app-store.md)
|
||||
Decision to distribute the application directly through downloadable installers outside the Mac App Store, prioritizing development velocity and cost efficiency while maintaining security through code signing and notarization.
|
||||
|
||||
## Contributing
|
||||
|
||||
When making significant architectural decisions:
|
||||
|
|
|
|||
|
|
@ -153,6 +153,8 @@
|
|||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = EAC944E3295E56EA004EE34F /* Build configuration list for PBXNativeTarget "OMG" */;
|
||||
buildPhases = (
|
||||
EA67B2142F17DE3400A145FB /* SwiftFormat */,
|
||||
EA67B2152F17DF3300A145FB /* SwiftLint */,
|
||||
EAC944D0295E56E9004EE34F /* Sources */,
|
||||
EAC944D1295E56E9004EE34F /* Frameworks */,
|
||||
EAC944D2295E56E9004EE34F /* Resources */,
|
||||
|
|
@ -239,6 +241,47 @@
|
|||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
EA67B2142F17DE3400A145FB /* SwiftFormat */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = SwiftFormat;
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/zsh;
|
||||
shellScript = "#!/bin/zsh\n\n# Add Homebrew paths to PATH\nexport PATH=\"/opt/homebrew/bin:/usr/local/bin:$PATH\"\n\n# Exit successfully if swiftformat is not installed\nif ! command -v swiftformat &> /dev/null; then\n echo \"warning: SwiftFormat not installed, skipping formatting\"\n exit 0\nfi\n\n# Only run on actual builds, not indexing\nif [[ \"${ACTION}\" == \"indexbuild\" ]]; then\n exit 0\nfi\n\n# Change to source root to ensure .swiftformat is accessible\ncd \"${SRCROOT}\" || exit 0\n\n# Run SwiftFormat on the project directory\nswiftformat . --lint --lenient 2>&1 | while IFS= read -r line; do\n echo \"${line}\"\ndone\n\necho \"SwiftFormat completed successfully\"\nexit 0\n";
|
||||
};
|
||||
EA67B2152F17DF3300A145FB /* SwiftLint */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = SwiftLint;
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/zsh;
|
||||
shellScript = "#!/bin/zsh\n\n# Add Homebrew paths to PATH\nexport PATH=\"/opt/homebrew/bin:/usr/local/bin:$PATH\"\n\n# Exit successfully if swiftlint is not installed\nif ! command -v swiftlint &> /dev/null; then\n echo \"warning: SwiftLint not installed, skipping linting\"\n exit 0\nfi\n\n# Only run on actual builds, not indexing\nif [[ \"${ACTION}\" == \"indexbuild\" ]]; then\n exit 0\nfi\n\n# Change to source root to ensure .swiftlint.yml is accessible\ncd \"${SRCROOT}\" || exit 0\n\n# Run SwiftLint and report violations as Xcode warnings/errors\nswiftlint lint --quiet --reporter xcode\n\nexit 0\n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
EAC944D0295E56E9004EE34F /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
|
|
@ -390,6 +433,7 @@
|
|||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "OMG/Supporting Files/Info.plist";
|
||||
|
|
@ -408,7 +452,7 @@
|
|||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.1;
|
||||
MARKETING_VERSION = 1.4;
|
||||
MARKETING_VERSION = 1.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.otaviocc.OMG;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
|
|
@ -439,6 +483,7 @@
|
|||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "OMG/Supporting Files/Info.plist";
|
||||
|
|
@ -457,7 +502,7 @@
|
|||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.1;
|
||||
MARKETING_VERSION = 1.4;
|
||||
MARKETING_VERSION = 1.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.otaviocc.OMG;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
|
|
|
|||
|
|
@ -207,6 +207,7 @@ struct TritonEnvironment: TritonEnvironmentProtocol {
|
|||
)
|
||||
}
|
||||
|
||||
// swiftlint:disable function_body_length
|
||||
init(
|
||||
authSessionServiceFactory: any AuthSessionServiceFactoryProtocol,
|
||||
sessionServiceFactory: any SessionServiceFactoryProtocol,
|
||||
|
|
@ -233,11 +234,9 @@ struct TritonEnvironment: TritonEnvironmentProtocol {
|
|||
allocation: .static
|
||||
) { container in
|
||||
let authSessionService = container.resolve() as any AuthSessionServiceProtocol
|
||||
return networkClient.makeOMGAPIClient(
|
||||
authTokenProvider: {
|
||||
await authSessionService.accessToken
|
||||
}
|
||||
)
|
||||
return networkClient.makeOMGAPIClient {
|
||||
await authSessionService.accessToken
|
||||
}
|
||||
}
|
||||
|
||||
container.register(
|
||||
|
|
@ -375,4 +374,5 @@ struct TritonEnvironment: TritonEnvironmentProtocol {
|
|||
)
|
||||
}
|
||||
}
|
||||
// swiftlint:enable function_body_length
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,8 +41,8 @@ struct TritonScene: Scene {
|
|||
#endif
|
||||
.environment(makeAccountUpdateService())
|
||||
.handlesExternalEvents(
|
||||
preferring: Set(arrayLiteral: "viewer"),
|
||||
allowing: Set(arrayLiteral: "*")
|
||||
preferring: ["viewer"],
|
||||
allowing: ["*"]
|
||||
)
|
||||
.onAppear {
|
||||
environment
|
||||
|
|
|
|||
|
|
@ -70,10 +70,8 @@ actor AccountUpdateRepository: AccountUpdateRepositoryProtocol {
|
|||
await fetchAccountInformation()
|
||||
}
|
||||
|
||||
for await isLoggedIn in authSessionService.observeLoginState() {
|
||||
if isLoggedIn {
|
||||
await fetchAccountInformation()
|
||||
}
|
||||
for await isLoggedIn in authSessionService.observeLoginState() where isLoggedIn {
|
||||
await fetchAccountInformation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,10 +25,8 @@ final class AuthAppViewModel {
|
|||
let currentState = await authSessionService.isLoggedIn
|
||||
isLoggedIn = currentState
|
||||
|
||||
for await loginState in authSessionService.observeLoginState() {
|
||||
if isLoggedIn != loginState {
|
||||
isLoggedIn = loginState
|
||||
}
|
||||
for await loginState in authSessionService.observeLoginState() where isLoggedIn != loginState {
|
||||
isLoggedIn = loginState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ final class AuthSessionServiceTests: XCTestCase {
|
|||
)
|
||||
}
|
||||
|
||||
func testObserveLoginStateYieldsCurrentState() async {
|
||||
func testObserveLoginStateYieldsCurrentState() async throws {
|
||||
// Given
|
||||
let initialState = await service.isLoggedIn
|
||||
|
||||
|
|
@ -97,9 +97,8 @@ final class AuthSessionServiceTests: XCTestCase {
|
|||
var iterator = stream.makeAsyncIterator()
|
||||
let firstValue = await iterator.next()
|
||||
|
||||
XCTAssertEqual(
|
||||
firstValue,
|
||||
false,
|
||||
XCTAssertFalse(
|
||||
try XCTUnwrap(firstValue),
|
||||
"It should yielded the first value correctly"
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -185,10 +185,10 @@ public struct SelectionToolbarItem<Option: Hashable & CaseIterable>: View {
|
|||
DropdownMenuView(
|
||||
options: options,
|
||||
selection: selection,
|
||||
itemLabel: itemLabel,
|
||||
label: {
|
||||
AnyView(style.makeLabel(helpText: helpText ?? style.defaultHelpText))
|
||||
}
|
||||
)
|
||||
itemLabel: itemLabel
|
||||
) {
|
||||
let helpText = helpText ?? style.defaultHelpText
|
||||
return AnyView(style.makeLabel(helpText: helpText))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,14 +78,16 @@ public struct TagListView: View {
|
|||
#Preview("Regular Tags") {
|
||||
TagListView(
|
||||
tags: ["swift", "ios", "macos"],
|
||||
helpText: { "Select \($0)" }
|
||||
) { _ in }
|
||||
helpText: { "Select \($0)" },
|
||||
action: { _ in }
|
||||
)
|
||||
}
|
||||
|
||||
#Preview("Remove Tags") {
|
||||
TagListView(
|
||||
tags: ["swift", "ios", "macos"],
|
||||
style: .remove,
|
||||
helpText: { "Remove \($0)" }
|
||||
) { _ in }
|
||||
helpText: { "Remove \($0)" },
|
||||
action: { _ in }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import Testing
|
||||
@testable import FoundationExtensions
|
||||
|
||||
// swiftlint:disable file_length type_body_length
|
||||
|
||||
@Suite("ArrayContains Tests")
|
||||
struct ArrayContainsTests {
|
||||
|
||||
|
|
@ -535,3 +537,5 @@ struct ArrayContainsTests {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:enable file_length type_body_length
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ struct StringSlugTests {
|
|||
|
||||
// Then
|
||||
#expect(
|
||||
result == "",
|
||||
result.isEmpty,
|
||||
"It should handle empty string"
|
||||
)
|
||||
}
|
||||
|
|
@ -105,7 +105,7 @@ struct StringSlugTests {
|
|||
|
||||
// Then
|
||||
#expect(
|
||||
result == "",
|
||||
result.isEmpty,
|
||||
"It should handle string with only whitespace"
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import Foundation
|
|||
import Testing
|
||||
@testable import FoundationExtensions
|
||||
|
||||
// swiftlint:disable file_length type_body_length
|
||||
|
||||
@Suite("StringWeblog Tests")
|
||||
struct StringWeblogTests {
|
||||
|
||||
|
|
@ -496,3 +498,5 @@ struct StringWeblogTests {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:enable file_length type_body_length
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ struct NowEnvironment {
|
|||
)
|
||||
}
|
||||
|
||||
// swiftlint:disable function_body_length
|
||||
init(
|
||||
networkServiceFactory: NowNetworkServiceFactoryProtocol,
|
||||
persistenceServiceFactory: NowPersistenceServiceFactoryProtocol,
|
||||
|
|
@ -113,4 +114,5 @@ struct NowEnvironment {
|
|||
)
|
||||
}
|
||||
}
|
||||
// swiftlint:enable function_body_length
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,12 +12,12 @@
|
|||
count: Int,
|
||||
in container: ModelContainer
|
||||
) {
|
||||
for i in 0..<count {
|
||||
for index in 0..<count {
|
||||
let now = Now(
|
||||
listed: true,
|
||||
markdown: "Foobar \(i)",
|
||||
markdown: "Foobar \(index)",
|
||||
submitted: true,
|
||||
timestamp: 123_123 * Double(i),
|
||||
timestamp: 123_123 * Double(index),
|
||||
address: "otaviocc"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -17,8 +17,15 @@
|
|||
}
|
||||
}
|
||||
|
||||
func fetchNowPage(for address: String) async throws {}
|
||||
func updateNowPage(address: String, content: String, listed: Bool) async throws {}
|
||||
func fetchNowPage(
|
||||
for address: String
|
||||
) async throws {}
|
||||
|
||||
func updateNowPage(
|
||||
address: String,
|
||||
content: String,
|
||||
listed: Bool
|
||||
) async throws {}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
|
|
|||
|
|
@ -37,7 +37,12 @@
|
|||
// MARK: - Public
|
||||
|
||||
func fetchNowPage() async throws {}
|
||||
func updateNowPage(address: String, content: String, isListed: Bool) async throws {}
|
||||
|
||||
func updateNowPage(
|
||||
address: String,
|
||||
content: String,
|
||||
isListed: Bool
|
||||
) async throws {}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
|
|
|||
|
|
@ -3,5 +3,6 @@ import XCTest
|
|||
|
||||
final class NowTests: XCTestCase {
|
||||
|
||||
// swiftlint:disable:next empty_xctest_method
|
||||
func testExample() throws {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,16 +5,4 @@ public struct CreateOrUpdatePasteRequest: Encodable, Sendable {
|
|||
let title: String
|
||||
let content: String
|
||||
let listed: Bool
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(
|
||||
title: String,
|
||||
content: String,
|
||||
listed: Bool
|
||||
) {
|
||||
self.title = title
|
||||
self.content = content
|
||||
self.listed = listed
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,16 +5,4 @@ public struct CreatePURLRequest: Encodable, Sendable {
|
|||
let address: String
|
||||
let name: String
|
||||
let url: String
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(
|
||||
address: String,
|
||||
name: String,
|
||||
url: String
|
||||
) {
|
||||
self.address = address
|
||||
self.name = name
|
||||
self.url = url
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,14 +4,4 @@ public struct UpdateNowPageRequest: Encodable, Sendable {
|
|||
|
||||
let content: String
|
||||
let listed: Int
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(
|
||||
content: String,
|
||||
listed: Int
|
||||
) {
|
||||
self.content = content
|
||||
self.listed = listed
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,14 +4,4 @@ public struct UpdateWebpageRequest: Encodable, Sendable {
|
|||
|
||||
let content: String
|
||||
let publish: Bool
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(
|
||||
content: String,
|
||||
publish: Bool
|
||||
) {
|
||||
self.content = content
|
||||
self.publish = publish
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,4 @@ public struct UploadPictureRequest: Encodable, Sendable {
|
|||
// MARK: - Properties
|
||||
|
||||
let pic: String
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
init(
|
||||
pic: String
|
||||
) {
|
||||
self.pic = pic
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,22 +40,22 @@ struct AuthRequestFactoryTests {
|
|||
)
|
||||
|
||||
#expect(
|
||||
queryItems.contains(where: { $0.name == "client_id" }),
|
||||
queryItems.contains { $0.name == "client_id" },
|
||||
"It should include client_id parameter"
|
||||
)
|
||||
|
||||
#expect(
|
||||
queryItems.contains(where: { $0.name == "scope" && $0.value == "everything" }),
|
||||
queryItems.contains { $0.name == "scope" && $0.value == "everything" },
|
||||
"It should include scope parameter with 'everything' value"
|
||||
)
|
||||
|
||||
#expect(
|
||||
queryItems.contains(where: { $0.name == "response_type" && $0.value == "code" }),
|
||||
queryItems.contains { $0.name == "response_type" && $0.value == "code" },
|
||||
"It should include response_type parameter with 'code' value"
|
||||
)
|
||||
|
||||
#expect(
|
||||
queryItems.contains(where: { $0.name == "redirect_uri" }),
|
||||
queryItems.contains { $0.name == "redirect_uri" },
|
||||
"It should include redirect_uri parameter"
|
||||
)
|
||||
}
|
||||
|
|
@ -82,27 +82,27 @@ struct AuthRequestFactoryTests {
|
|||
let queryItems = request.queryItems
|
||||
|
||||
#expect(
|
||||
queryItems.contains(where: { $0.name == "client_id" }),
|
||||
queryItems.contains { $0.name == "client_id" },
|
||||
"It should include client_id query parameter"
|
||||
)
|
||||
|
||||
#expect(
|
||||
queryItems.contains(where: { $0.name == "client_secret" }),
|
||||
queryItems.contains { $0.name == "client_secret" },
|
||||
"It should include client_secret query parameter"
|
||||
)
|
||||
|
||||
#expect(
|
||||
queryItems.contains(where: { $0.name == "redirect_uri" }),
|
||||
queryItems.contains { $0.name == "redirect_uri" },
|
||||
"It should include redirect_uri query parameter"
|
||||
)
|
||||
|
||||
#expect(
|
||||
queryItems.contains(where: { $0.name == "code" && $0.value == authCode }),
|
||||
queryItems.contains { $0.name == "code" && $0.value == authCode },
|
||||
"It should include code query parameter with provided auth code"
|
||||
)
|
||||
|
||||
#expect(
|
||||
queryItems.contains(where: { $0.name == "scope" && $0.value == "everything" }),
|
||||
queryItems.contains { $0.name == "scope" && $0.value == "everything" },
|
||||
"It should include scope query parameter"
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -232,7 +232,7 @@ struct PicsRequestFactoryTests {
|
|||
}
|
||||
|
||||
@Test("It should create picture edit request with empty tags array")
|
||||
func makeEditPictureRequest_withOnlyWithEmptyTagsArray_createsRequest() {
|
||||
func makeEditPictureRequest_withOnlyWithEmptyTagsArray_createsRequest() throws {
|
||||
// Given
|
||||
let address = "dave"
|
||||
let pictureID = "pic-456"
|
||||
|
|
@ -256,23 +256,26 @@ struct PicsRequestFactoryTests {
|
|||
"It should use PUT method"
|
||||
)
|
||||
|
||||
let body = try #require(request.body)
|
||||
let bodyTags = try #require(body.tags)
|
||||
|
||||
#expect(
|
||||
request.body?.tags == "",
|
||||
bodyTags.isEmpty == true,
|
||||
"It should include tags in request body"
|
||||
)
|
||||
|
||||
#expect(
|
||||
request.body?.caption == nil,
|
||||
body.caption == nil,
|
||||
"It should have nil caption when not provided"
|
||||
)
|
||||
|
||||
#expect(
|
||||
request.body?.alt == nil,
|
||||
body.alt == nil,
|
||||
"It should have nil alt text when not provided"
|
||||
)
|
||||
|
||||
#expect(
|
||||
request.body?.isHidden == nil,
|
||||
body.isHidden == nil,
|
||||
"It should have nil isHidden when not provided"
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ struct PURLsEnvironment {
|
|||
)
|
||||
}
|
||||
|
||||
// swiftlint:disable function_body_length
|
||||
init(
|
||||
networkServiceFactory: PURLsNetworkServiceFactoryProtocol,
|
||||
persistenceServiceFactory: PURLsPersistenceServiceFactoryProtocol,
|
||||
|
|
@ -123,4 +124,5 @@ struct PURLsEnvironment {
|
|||
)
|
||||
}
|
||||
}
|
||||
// swiftlint:enable function_body_length
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,14 @@
|
|||
|
||||
private final class FakeClipboardService: ClipboardServiceProtocol {
|
||||
|
||||
func copy(_ string: String) {}
|
||||
func copy(_ data: Data, type: String) {}
|
||||
func copy(
|
||||
_ string: String
|
||||
) {}
|
||||
|
||||
func copy(
|
||||
_ data: Data,
|
||||
type: String
|
||||
) {}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@
|
|||
count: Int,
|
||||
in container: ModelContainer
|
||||
) {
|
||||
for i in 0..<count {
|
||||
for index in 0..<count {
|
||||
let purl = PURL(
|
||||
name: "purl\(i)",
|
||||
url: URL(string: "http://subdomain\(i).otavio.lol")!,
|
||||
name: "purl\(index)",
|
||||
url: URL(string: "http://subdomain\(index).otavio.lol")!,
|
||||
address: "otaviocc"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -17,9 +17,20 @@
|
|||
}
|
||||
}
|
||||
|
||||
func fetchPURLs(for address: String) {}
|
||||
func addPURL(address: String, name: String, url: String) {}
|
||||
func deletePURL(address: String, name: String) async throws {}
|
||||
func fetchPURLs(
|
||||
for address: String
|
||||
) {}
|
||||
|
||||
func addPURL(
|
||||
address: String,
|
||||
name: String,
|
||||
url: String
|
||||
) {}
|
||||
|
||||
func deletePURL(
|
||||
address: String,
|
||||
name: String
|
||||
) async throws {}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
|
|
|||
|
|
@ -37,8 +37,17 @@
|
|||
// MARK: - Public
|
||||
|
||||
func fetchPURLs() {}
|
||||
func addPURL(address: String, name: String, url: String) {}
|
||||
func deletePURL(address: String, name: String) async throws {}
|
||||
|
||||
func addPURL(
|
||||
address: String,
|
||||
name: String,
|
||||
url: String
|
||||
) {}
|
||||
|
||||
func deletePURL(
|
||||
address: String,
|
||||
name: String
|
||||
) async throws {}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
|
|
|||
|
|
@ -3,5 +3,6 @@ import XCTest
|
|||
|
||||
final class PURLsTests: XCTestCase {
|
||||
|
||||
// swiftlint:disable:next empty_xctest_method
|
||||
func testExample() throws {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ struct PastebinEnvironment {
|
|||
)
|
||||
}
|
||||
|
||||
// swiftlint:disable function_body_length
|
||||
init(
|
||||
networkServiceFactory: PastebinNetworkServiceFactoryProtocol,
|
||||
persistenceServiceFactory: PastebinPersistenceServiceFactoryProtocol,
|
||||
|
|
@ -123,4 +124,5 @@ struct PastebinEnvironment {
|
|||
)
|
||||
}
|
||||
}
|
||||
// swiftlint:enable function_body_length
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@
|
|||
private final class FakeClipboardService: ClipboardServiceProtocol {
|
||||
|
||||
func copy(_ string: String) {}
|
||||
func copy(_ data: Data, type: String) {}
|
||||
|
||||
func copy(
|
||||
_ data: Data,
|
||||
type: String
|
||||
) {}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
|
|
|||
|
|
@ -12,13 +12,13 @@
|
|||
count: Int,
|
||||
in container: ModelContainer
|
||||
) {
|
||||
for i in 0..<count {
|
||||
for index in 0..<count {
|
||||
let paste = Paste(
|
||||
title: "paste\(i).md",
|
||||
title: "paste\(index).md",
|
||||
content: "hello, world!",
|
||||
timestamp: 123_123_123,
|
||||
address: "otaviocc",
|
||||
listed: i % 2 == 0
|
||||
listed: index % 2 == 0
|
||||
)
|
||||
|
||||
container.mainContext.insert(
|
||||
|
|
|
|||
|
|
@ -17,9 +17,21 @@
|
|||
}
|
||||
}
|
||||
|
||||
func fetchPastes(for address: String) async throws {}
|
||||
func createOrUpdatePaste(address: String, title: String, content: String, listed: Bool) async throws {}
|
||||
func deletePaste(address: String, title: String) async throws {}
|
||||
func fetchPastes(
|
||||
for address: String
|
||||
) async throws {}
|
||||
|
||||
func createOrUpdatePaste(
|
||||
address: String,
|
||||
title: String,
|
||||
content: String,
|
||||
listed: Bool
|
||||
) async throws {}
|
||||
|
||||
func deletePaste(
|
||||
address: String,
|
||||
title: String
|
||||
) async throws {}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
|
|
|||
|
|
@ -37,8 +37,18 @@
|
|||
// MARK: - Public
|
||||
|
||||
func fetchPastes() async throws {}
|
||||
func createOrUpdatePaste(address: String, title: String, content: String, isListed: Bool) async throws {}
|
||||
func deletePaste(address: String, title: String) async throws {}
|
||||
|
||||
func createOrUpdatePaste(
|
||||
address: String,
|
||||
title: String,
|
||||
content: String,
|
||||
isListed: Bool
|
||||
) async throws {}
|
||||
|
||||
func deletePaste(
|
||||
address: String,
|
||||
title: String
|
||||
) async throws {}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
|
|
|||
|
|
@ -3,5 +3,6 @@ import XCTest
|
|||
|
||||
final class PastebinTests: XCTestCase {
|
||||
|
||||
// swiftlint:disable:next empty_xctest_method
|
||||
func testExample() throws {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ struct PicsEnvironment {
|
|||
)
|
||||
}
|
||||
|
||||
// swiftlint:disable function_body_length
|
||||
init(
|
||||
networkServiceFactory: PicsNetworkServiceFactoryProtocol,
|
||||
persistenceServiceFactory: PicsPersistenceServiceFactoryProtocol,
|
||||
|
|
@ -119,4 +120,5 @@ struct PicsEnvironment {
|
|||
)
|
||||
}
|
||||
}
|
||||
// swiftlint:enable function_body_length
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,14 @@
|
|||
|
||||
private final class FakeClipboardService: ClipboardServiceProtocol {
|
||||
|
||||
func copy(_ string: String) {}
|
||||
func copy(_ data: Data, type: String) {}
|
||||
func copy(
|
||||
_ string: String
|
||||
) {}
|
||||
|
||||
func copy(
|
||||
_ data: Data,
|
||||
type: String
|
||||
) {}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
|
|
|||
|
|
@ -12,8 +12,15 @@
|
|||
|
||||
// MARK: - Public
|
||||
|
||||
func fetchPictures(for address: String) async throws -> [PictureResponse] { [] }
|
||||
func deletePicture(address: String, pictureID: String) async throws {}
|
||||
func fetchPictures(
|
||||
for address: String
|
||||
) async throws -> [PictureResponse] { [] }
|
||||
|
||||
func deletePicture(
|
||||
address: String,
|
||||
pictureID: String
|
||||
) async throws {}
|
||||
|
||||
func updatePicture(
|
||||
address: String,
|
||||
pictureID: String,
|
||||
|
|
@ -21,6 +28,7 @@
|
|||
alt: String,
|
||||
tags: [String]
|
||||
) async throws {}
|
||||
|
||||
func uploadPicture(
|
||||
address: String,
|
||||
data: Data,
|
||||
|
|
|
|||
|
|
@ -39,7 +39,12 @@
|
|||
// MARK: - Public
|
||||
|
||||
func fetchPictures() async throws {}
|
||||
func deletePicture(address: String, pictureID: String) async throws {}
|
||||
|
||||
func deletePicture(
|
||||
address: String,
|
||||
pictureID: String
|
||||
) async throws {}
|
||||
|
||||
func updatePicture(
|
||||
address: String,
|
||||
pictureID: String,
|
||||
|
|
@ -47,6 +52,7 @@
|
|||
alt: String,
|
||||
tags: [String]
|
||||
) async throws {}
|
||||
|
||||
func uploadPicture(
|
||||
address: String,
|
||||
data: Data,
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@
|
|||
count: Int = 3,
|
||||
in container: ModelContainer
|
||||
) {
|
||||
for i in 0..<count {
|
||||
for index in 0..<count {
|
||||
SomePicture.makePicture(
|
||||
created: Double(i * i),
|
||||
created: Double(index * index),
|
||||
in: container
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ struct EditPictureView: View {
|
|||
.autocorrectionDisabled(true)
|
||||
.font(.body.monospaced())
|
||||
.textFieldCard()
|
||||
.help("Enter a tag and press the return key to add it")
|
||||
.help("Enter a tag and press the return key to add it, or press tab to select the first suggestion")
|
||||
.onSubmit {
|
||||
withAnimation {
|
||||
viewModel.addTag(viewModel.tagInput)
|
||||
|
|
@ -83,6 +83,14 @@ struct EditPictureView: View {
|
|||
.onChange(of: viewModel.tagInput) {
|
||||
viewModel.updateTagSuggestions(from: existingTags.map(\.title))
|
||||
}
|
||||
.onKeyPress(.tab) {
|
||||
do {
|
||||
try viewModel.selectFistTagSuggestion()
|
||||
return .handled
|
||||
} catch {
|
||||
return .ignored
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
|
@ -90,12 +98,13 @@ struct EditPictureView: View {
|
|||
if !viewModel.suggestedTags.isEmpty {
|
||||
TagListView(
|
||||
tags: viewModel.suggestedTags,
|
||||
helpText: { "Add existing tag '\($0)'" }
|
||||
) { tag in
|
||||
withAnimation {
|
||||
viewModel.addTag(tag)
|
||||
helpText: { "Add existing tag '\($0)'" },
|
||||
action: { tag in
|
||||
withAnimation {
|
||||
viewModel.addTag(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -105,12 +114,13 @@ struct EditPictureView: View {
|
|||
TagListView(
|
||||
tags: viewModel.tags,
|
||||
style: .remove,
|
||||
helpText: { "Remove tag '\($0)'" }
|
||||
) { tag in
|
||||
withAnimation {
|
||||
viewModel.removeTag(tag)
|
||||
helpText: { "Remove tag '\($0)'" },
|
||||
action: { tag in
|
||||
withAnimation {
|
||||
viewModel.removeTag(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,13 @@ import PicsRepository
|
|||
@Observable
|
||||
final class EditPictureViewModel {
|
||||
|
||||
// MARK: - Nested types
|
||||
|
||||
enum TagSelectionError: Error {
|
||||
|
||||
case noSuggestions
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
let pictureID: String
|
||||
|
|
@ -108,4 +115,12 @@ final class EditPictureViewModel {
|
|||
func removeTag(_ tag: String) {
|
||||
tags.removeAll { $0 == tag }
|
||||
}
|
||||
|
||||
func selectFistTagSuggestion() throws(TagSelectionError) {
|
||||
guard let tag = suggestedTags.first else {
|
||||
throw .noSuggestions
|
||||
}
|
||||
|
||||
addTag(tag)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ struct PicturesListView: View {
|
|||
_pictures = .init(viewModel.fetchDescriptor())
|
||||
}
|
||||
|
||||
// MARK; - Public
|
||||
// MARK: - Public
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ struct UploadView: View {
|
|||
.autocorrectionDisabled(true)
|
||||
.font(.body.monospaced())
|
||||
.textFieldCard()
|
||||
.help("Enter a tag and press the return key to add it")
|
||||
.help("Enter a tag and press the return key to add it, or press tab to select the first suggestion")
|
||||
.onSubmit {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
viewModel.addTag(viewModel.tagInput)
|
||||
|
|
@ -194,6 +194,14 @@ struct UploadView: View {
|
|||
.onChange(of: viewModel.tagInput) {
|
||||
viewModel.updateTagSuggestions(from: existingTags.map(\.title))
|
||||
}
|
||||
.onKeyPress(.tab) {
|
||||
do {
|
||||
try viewModel.selectFistTagSuggestion()
|
||||
return .handled
|
||||
} catch {
|
||||
return .ignored
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
|
@ -201,12 +209,13 @@ struct UploadView: View {
|
|||
if !viewModel.suggestedTags.isEmpty {
|
||||
TagListView(
|
||||
tags: viewModel.suggestedTags,
|
||||
helpText: { "Add existing tag '\($0)'" }
|
||||
) { tag in
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
viewModel.addTag(tag)
|
||||
helpText: { "Add existing tag '\($0)'" },
|
||||
action: { tag in
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
viewModel.addTag(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -216,12 +225,13 @@ struct UploadView: View {
|
|||
TagListView(
|
||||
tags: viewModel.tags,
|
||||
style: .remove,
|
||||
helpText: { "Remove tag '\($0)'" }
|
||||
) { tag in
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
viewModel.removeTag(tag)
|
||||
helpText: { "Remove tag '\($0)'" },
|
||||
action: { tag in
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
viewModel.removeTag(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,13 @@ import UniformTypeIdentifiers
|
|||
@Observable
|
||||
final class UploadViewModel {
|
||||
|
||||
// MARK: - Nested types
|
||||
|
||||
enum TagSelectionError: Error {
|
||||
|
||||
case noSuggestions
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
var caption = ""
|
||||
|
|
@ -145,6 +152,14 @@ final class UploadViewModel {
|
|||
tags.removeAll { $0 == tag }
|
||||
}
|
||||
|
||||
func selectFistTagSuggestion() throws(TagSelectionError) {
|
||||
guard let tag = suggestedTags.first else {
|
||||
throw .noSuggestions
|
||||
}
|
||||
|
||||
addTag(tag)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func setUpObservers() {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ struct StatusEnvironment {
|
|||
)
|
||||
}
|
||||
|
||||
// swiftlint:disable function_body_length
|
||||
init(
|
||||
repositoryFactory: StatusRepositoryFactoryProtocol,
|
||||
networkServiceFactory: StatusNetworkServiceFactoryProtocol,
|
||||
|
|
@ -120,4 +121,5 @@ struct StatusEnvironment {
|
|||
)
|
||||
}
|
||||
}
|
||||
// swiftlint:enable function_body_length
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,14 @@
|
|||
|
||||
private final class FakeClipboardService: ClipboardServiceProtocol {
|
||||
|
||||
func copy(_ string: String) {}
|
||||
func copy(_ data: Data, type: String) {}
|
||||
func copy(
|
||||
_ string: String
|
||||
) {}
|
||||
|
||||
func copy(
|
||||
_ data: Data,
|
||||
type: String
|
||||
) {}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
|
|
|||
|
|
@ -12,11 +12,11 @@
|
|||
count: Int,
|
||||
in container: ModelContainer
|
||||
) {
|
||||
for i in 0..<count {
|
||||
for index in 0..<count {
|
||||
let status = Status(
|
||||
username: "user\(i)",
|
||||
statusID: "(i)",
|
||||
timestamp: Double(i),
|
||||
username: "user\(index)",
|
||||
statusID: "(index)",
|
||||
timestamp: Double(index),
|
||||
icon: "🤣",
|
||||
content: "Nulla purus urna, bibendum nec purus."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -45,9 +45,10 @@ public struct StatusApp: View {
|
|||
ToolbarItemGroup {
|
||||
SelectionToolbarItem(
|
||||
options: StatusListFilter.allCases,
|
||||
selection: $filter,
|
||||
itemLabel: { $0.localizedTitle }
|
||||
)
|
||||
selection: $filter
|
||||
) { label in
|
||||
label.localizedTitle
|
||||
}
|
||||
.selectionToolbarItemStyle(FilterSelectionToolbarItemStyle())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,13 @@ struct ClipboardService: ClipboardServiceProtocol {
|
|||
service.copy(string)
|
||||
}
|
||||
|
||||
func copy(_ data: Data, type: String) {
|
||||
service.copy(data, type: type)
|
||||
func copy(
|
||||
_ data: Data,
|
||||
type: String
|
||||
) {
|
||||
service.copy(
|
||||
data,
|
||||
type: type
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,10 @@
|
|||
)
|
||||
}
|
||||
|
||||
func copy(_ data: Data, type: String) {
|
||||
func copy(
|
||||
_ data: Data,
|
||||
type: String
|
||||
) {
|
||||
NSPasteboard
|
||||
.general
|
||||
.clearContents()
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ struct WeblogEnvironment {
|
|||
)
|
||||
}
|
||||
|
||||
// swiftlint:disable function_body_length
|
||||
init(
|
||||
networkServiceFactory: WeblogNetworkServiceFactoryProtocol,
|
||||
persistenceServiceFactory: WeblogPersistenceServiceFactoryProtocol,
|
||||
|
|
@ -119,4 +120,5 @@ struct WeblogEnvironment {
|
|||
)
|
||||
}
|
||||
}
|
||||
// swiftlint:enable function_body_length
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,14 @@
|
|||
|
||||
private final class FakeClipboardService: ClipboardServiceProtocol {
|
||||
|
||||
func copy(_ string: String) {}
|
||||
func copy(_ data: Data, type: String) {}
|
||||
func copy(
|
||||
_ string: String
|
||||
) {}
|
||||
|
||||
func copy(
|
||||
_ data: Data,
|
||||
type: String
|
||||
) {}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
|
|
|||
|
|
@ -27,14 +27,14 @@
|
|||
}
|
||||
|
||||
static func makeEntryResponses(count: Int = 5) -> [EntryResponse] {
|
||||
(0..<count).map { i in
|
||||
(0..<count).map { index in
|
||||
makeEntryResponse(
|
||||
id: "entry-\(i)",
|
||||
location: "test-entry-\(i)",
|
||||
date: Double(1_700_000_000 + (i * 86400)),
|
||||
status: i % 3 == 0 ? "draft" : "published",
|
||||
title: "Test Entry \(i)",
|
||||
body: "Content for test entry \(i)"
|
||||
id: "entry-\(index)",
|
||||
location: "test-entry-\(index)",
|
||||
date: Double(1_700_000_000 + (index * 86400)),
|
||||
status: index % 3 == 0 ? "draft" : "published",
|
||||
title: "Test Entry \(index)",
|
||||
body: "Content for test entry \(index)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,14 +12,14 @@
|
|||
count: Int,
|
||||
in container: ModelContainer
|
||||
) {
|
||||
for i in 0..<count {
|
||||
for index in 0..<count {
|
||||
let entry = WeblogEntry(
|
||||
id: "entry-\(i)",
|
||||
title: "Test Entry \(i)",
|
||||
body: "This is the body for test entry \(i). Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
|
||||
date: Double(1_700_000_000 + (i * 86400)),
|
||||
status: i % 3 == 0 ? "draft" : "published",
|
||||
location: "test-entry-\(i)",
|
||||
id: "entry-\(index)",
|
||||
title: "Test Entry \(index)",
|
||||
body: "This is the body for test entry \(index). Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
|
||||
date: Double(1_700_000_000 + (index * 86400)),
|
||||
status: index % 3 == 0 ? "draft" : "published",
|
||||
location: "test-entry-\(index)",
|
||||
address: "otaviocc"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -12,11 +12,16 @@
|
|||
|
||||
// MARK: - Public
|
||||
|
||||
func fetchWeblogEntry(for address: String, entryID: String) async throws -> EntryResponse {
|
||||
func fetchWeblogEntry(
|
||||
for address: String,
|
||||
entryID: String
|
||||
) async throws -> EntryResponse {
|
||||
EntryResponseMother.makeEntryResponse()
|
||||
}
|
||||
|
||||
func fetchWeblogEntries(for address: String) async throws -> [EntryResponse] {
|
||||
func fetchWeblogEntries(
|
||||
for address: String
|
||||
) async throws -> [EntryResponse] {
|
||||
EntryResponseMother.makeEntryResponses(count: 2)
|
||||
}
|
||||
|
||||
|
|
@ -41,7 +46,10 @@
|
|||
EntryResponseMother.makeEntryResponse()
|
||||
}
|
||||
|
||||
func deleteWeblogEntry(address: String, entryID: String) async throws {}
|
||||
func deleteWeblogEntry(
|
||||
address: String,
|
||||
entryID: String
|
||||
) async throws {}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
|
|
|||
|
|
@ -39,7 +39,11 @@
|
|||
// MARK: - Public
|
||||
|
||||
func fetchEntries() async throws {}
|
||||
func deleteEntry(address: String, entryID: String) async throws {}
|
||||
|
||||
func deleteEntry(
|
||||
address: String,
|
||||
entryID: String
|
||||
) async throws {}
|
||||
|
||||
func createOrUpdateEntry(
|
||||
address: String,
|
||||
|
|
|
|||
|
|
@ -56,46 +56,18 @@ struct EditorView: View {
|
|||
private func makeSidebarView() -> some View {
|
||||
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 16) {
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Text("Date")
|
||||
.gridColumnAlignment(.trailing)
|
||||
|
||||
DatePicker(
|
||||
selection: $viewModel.date,
|
||||
displayedComponents: [.date]
|
||||
) {}
|
||||
.help("Select publication date")
|
||||
makeDatePicker()
|
||||
}
|
||||
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Text("Time")
|
||||
.gridColumnAlignment(.trailing)
|
||||
|
||||
DatePicker(
|
||||
selection: $viewModel.date,
|
||||
displayedComponents: [.hourAndMinute]
|
||||
) {}
|
||||
.help("Select publication time")
|
||||
makeTimePicker()
|
||||
}
|
||||
|
||||
Divider()
|
||||
.foregroundStyle(Color.accentColor)
|
||||
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Text("Status")
|
||||
.gridColumnAlignment(.trailing)
|
||||
|
||||
Picker(
|
||||
selection: $viewModel.status,
|
||||
content: {
|
||||
ForEach(WeblogEntryStatus.allCases) { status in
|
||||
Text(status.displayName)
|
||||
}
|
||||
},
|
||||
label: { EmptyView() }
|
||||
)
|
||||
.pickerStyle(.radioGroup)
|
||||
.labelsHidden()
|
||||
.help("Select publication visibility")
|
||||
makeStatusPicker()
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
|
@ -116,13 +88,56 @@ struct EditorView: View {
|
|||
.padding()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func makeDatePicker() -> some View {
|
||||
Text("Date")
|
||||
.gridColumnAlignment(.trailing)
|
||||
|
||||
DatePicker(
|
||||
selection: $viewModel.date,
|
||||
displayedComponents: [.date]
|
||||
) {}
|
||||
.help("Select publication date")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func makeTimePicker() -> some View {
|
||||
Text("Time")
|
||||
.gridColumnAlignment(.trailing)
|
||||
|
||||
DatePicker(
|
||||
selection: $viewModel.date,
|
||||
displayedComponents: [.hourAndMinute]
|
||||
) {}
|
||||
.help("Select publication time")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func makeStatusPicker() -> some View {
|
||||
Text("Status")
|
||||
.gridColumnAlignment(.trailing)
|
||||
|
||||
Picker(
|
||||
selection: $viewModel.status,
|
||||
content: {
|
||||
ForEach(WeblogEntryStatus.allCases) { status in
|
||||
Text(status.displayName)
|
||||
}
|
||||
},
|
||||
label: { EmptyView() }
|
||||
)
|
||||
.pickerStyle(.radioGroup)
|
||||
.labelsHidden()
|
||||
.help("Select publication visibility")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func makeTagInputView() -> some View {
|
||||
TextField("Add tag", text: $viewModel.tagInput)
|
||||
.autocorrectionDisabled(true)
|
||||
.font(.body.monospaced())
|
||||
.textFieldCard()
|
||||
.help("Enter a tag and press the return key to add it")
|
||||
.help("Enter a tag and press the return key to add it, or press tab to select the first suggestion")
|
||||
.onSubmit {
|
||||
withAnimation {
|
||||
viewModel.addTag(viewModel.tagInput)
|
||||
|
|
@ -131,6 +146,14 @@ struct EditorView: View {
|
|||
.onChange(of: viewModel.tagInput) {
|
||||
viewModel.updateTagSuggestions(from: existingTags.map(\.title))
|
||||
}
|
||||
.onKeyPress(.tab) {
|
||||
do {
|
||||
try viewModel.selectFistTagSuggestion()
|
||||
return .handled
|
||||
} catch {
|
||||
return .ignored
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
|
@ -138,12 +161,13 @@ struct EditorView: View {
|
|||
if !viewModel.suggestedTags.isEmpty {
|
||||
TagListView(
|
||||
tags: viewModel.suggestedTags,
|
||||
helpText: { "Add existing tag '\($0)'" }
|
||||
) { tag in
|
||||
withAnimation {
|
||||
viewModel.addTag(tag)
|
||||
helpText: { "Add existing tag '\($0)'" },
|
||||
action: { tag in
|
||||
withAnimation {
|
||||
viewModel.addTag(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -162,12 +186,13 @@ struct EditorView: View {
|
|||
TagListView(
|
||||
tags: viewModel.tags,
|
||||
style: .remove,
|
||||
helpText: { "Remove tag '\($0)'" }
|
||||
) { tag in
|
||||
withAnimation {
|
||||
viewModel.removeTag(tag)
|
||||
helpText: { "Remove tag '\($0)'" },
|
||||
action: { tag in
|
||||
withAnimation {
|
||||
viewModel.removeTag(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,13 @@ import WeblogRepository
|
|||
@Observable
|
||||
final class EditorViewModel {
|
||||
|
||||
// MARK: - Nested types
|
||||
|
||||
enum TagSelectionError: Error {
|
||||
|
||||
case noSuggestions
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
var body: String
|
||||
|
|
@ -115,4 +122,12 @@ final class EditorViewModel {
|
|||
func removeTag(_ tag: String) {
|
||||
tags.removeAll { $0 == tag }
|
||||
}
|
||||
|
||||
func selectFistTagSuggestion() throws(TagSelectionError) {
|
||||
guard let tag = suggestedTags.first else {
|
||||
throw .noSuggestions
|
||||
}
|
||||
|
||||
addTag(tag)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ struct WeblogEntriesListView: View {
|
|||
_entries = .init(viewModel.fetchDescriptor())
|
||||
}
|
||||
|
||||
// MARK; - Public
|
||||
// MARK: - Public
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
|
|
|
|||
|
|
@ -83,13 +83,20 @@ struct WeblogEntryView: View {
|
|||
@ViewBuilder
|
||||
private func makeContextualMenu() -> some View {
|
||||
makeEditEntryMenuItem()
|
||||
Divider()
|
||||
makeCopyEntryURLMenuItem()
|
||||
makeCopyMarkdownLinkMenuItem()
|
||||
Divider()
|
||||
makeOpenInBrowserMenuItem()
|
||||
makeShareMenuItem()
|
||||
makeShareOnStatuslogMenuItem()
|
||||
|
||||
if !viewModel.isDraft {
|
||||
Divider()
|
||||
makeCopyEntryURLMenuItem()
|
||||
makeCopyMarkdownLinkMenuItem()
|
||||
}
|
||||
|
||||
if !viewModel.isDraft {
|
||||
Divider()
|
||||
makeOpenInBrowserMenuItem()
|
||||
makeShareMenuItem()
|
||||
makeShareOnStatuslogMenuItem()
|
||||
}
|
||||
|
||||
Divider()
|
||||
makeDeleteEntryMenuItem()
|
||||
}
|
||||
|
|
@ -99,7 +106,7 @@ struct WeblogEntryView: View {
|
|||
Button {
|
||||
openEditor()
|
||||
} label: {
|
||||
Label("Edit Entry", systemImage: "link")
|
||||
Label("Edit Entry", systemImage: "pencil")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,10 @@ final class WeblogEntryViewModel: Identifiable {
|
|||
status.lowercased() != "live"
|
||||
}
|
||||
|
||||
var isDraft: Bool {
|
||||
status.lowercased() == "draft"
|
||||
}
|
||||
|
||||
private let repository: any WeblogRepositoryProtocol
|
||||
private let clipboardService: ClipboardServiceProtocol
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ struct WebpageEnvironment {
|
|||
)
|
||||
}
|
||||
|
||||
// swiftlint:disable function_body_length
|
||||
init(
|
||||
networkServiceFactory: WebpageNetworkServiceFactoryProtocol,
|
||||
persistenceServiceFactory: WebpagePersistenceServiceFactoryProtocol,
|
||||
|
|
@ -112,4 +113,5 @@ struct WebpageEnvironment {
|
|||
)
|
||||
}
|
||||
}
|
||||
// swiftlint:enable function_body_length
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,11 +12,11 @@
|
|||
count: Int,
|
||||
in container: ModelContainer
|
||||
) {
|
||||
for i in 0..<count {
|
||||
for index in 0..<count {
|
||||
let page = Webpage(
|
||||
address: "otaviocc",
|
||||
markdown: "Foobar \(i)",
|
||||
timestamp: 123_123 * Double(i)
|
||||
markdown: "Foobar \(index)",
|
||||
timestamp: 123_123 * Double(index)
|
||||
)
|
||||
|
||||
container.mainContext.insert(
|
||||
|
|
|
|||
|
|
@ -17,8 +17,14 @@
|
|||
}
|
||||
}
|
||||
|
||||
func fetchWebpage(for address: String) async throws {}
|
||||
func updateWebpage(address: String, content: String) async throws {}
|
||||
func fetchWebpage(
|
||||
for address: String
|
||||
) async throws {}
|
||||
|
||||
func updateWebpage(
|
||||
address: String,
|
||||
content: String
|
||||
) async throws {}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
|
|
|||
|
|
@ -37,7 +37,11 @@
|
|||
// MARK: - Public
|
||||
|
||||
func fetchWebpage() async throws {}
|
||||
func updateWebpage(address: String, content: String) async throws {}
|
||||
|
||||
func updateWebpage(
|
||||
address: String,
|
||||
content: String
|
||||
) async throws {}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
|
|
|||
|
|
@ -3,5 +3,6 @@ import XCTest
|
|||
|
||||
final class WebpageTests: XCTestCase {
|
||||
|
||||
// swiftlint:disable:next empty_xctest_method
|
||||
func testExample() throws {}
|
||||
}
|
||||
|
|
|
|||
15
README.md
15
README.md
|
|
@ -23,6 +23,7 @@ If you find this project useful, consider [buying me a coffee](https://ko-fi.com
|
|||
- [Multiple Addresses](#multiple-addresses)
|
||||
- [Install Triton](#install-triton)
|
||||
- [Thanks and Acknowledgments](#thanks-and-acknowledgments)
|
||||
- [Disclaimer](#disclaimer)
|
||||
- [App Store Distribution](#app-store-distribution)
|
||||
- [Why?](#why)
|
||||
- [What you CAN do](#what-you-can-do)
|
||||
|
|
@ -101,13 +102,21 @@ Switch seamlessly between multiple omg.lol addresses, all in one app.
|
|||
|
||||
You can download the latest pre-built version of Triton directly from the [GitHub Releases page](https://github.com/otaviocc/Triton/releases). Simply download the `.zip` file from the latest release, open it, and drag OMG.app to your Applications folder. This method is useful if you prefer manual installation or don't use Homebrew.
|
||||
|
||||
**Install via [Brew](https://brew.sh) 🤩**
|
||||
**Install via [Homebrew](https://brew.sh) 🤩**
|
||||
|
||||
```bash
|
||||
brew tap otaviocc/apps
|
||||
brew install --cask triton
|
||||
```
|
||||
|
||||
**Upgrade Triton**
|
||||
|
||||
If you installed Triton using Homebrew, you can upgrade to the latest version with:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask triton
|
||||
```
|
||||
|
||||
## Thanks and Acknowledgments
|
||||
|
||||
This project wouldn't exist without the amazing work of several people in the omg.lol community:
|
||||
|
|
@ -115,6 +124,10 @@ This project wouldn't exist without the amazing work of several people in the om
|
|||
- **[Adam](https://social.lol/@adam)** for creating and maintaining the incredible [omg.lol](https://omg.lol) service, its comprehensive API, and excellent documentation that made building this client possible
|
||||
- **[Eric](https://social.lol/@ericmwalk)** and **[Joanna](https://social.lol/@jmj)** for documenting the new [some.pics](https://some.pics) API endpoints for photo uploading and description editing, making the Pics feature integration seamless
|
||||
|
||||
## Disclaimer
|
||||
|
||||
Triton is an independent, third-party client for omg.lol and is not officially affiliated with, endorsed by, or connected to omg.lol or its creator. This app is a community-developed project that uses the publicly available omg.lol API. For official omg.lol support and services, please visit [omg.lol](https://omg.lol).
|
||||
|
||||
## App Store Distribution
|
||||
|
||||
This project is open source under the MIT License, which means you're free to use, modify, and learn from the code. However, I ask that you **please do not submit this app (or a trivially modified version) to the Apple App Store under a different name**.
|
||||
|
|
|
|||
Loading…
Reference in a new issue