commit 3e878667a10df8b10b1e659f34a2a2d77963e771 Author: Otávio Date: Mon Dec 15 12:07:17 2025 +0100 Add Triton App Signed-off-by: Otavio Cordeiro diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..d1efe91 --- /dev/null +++ b/.env.sample @@ -0,0 +1,3 @@ +OMGLOL_CLIENT_ID=your_client_id_here +OMGLOL_CLIENT_SECRET=your_client_secret_here +OMGLOL_REDIRECT_URI=omglol://oauth/callback diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..c8cafe1 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +ko_fi: otaviocc diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..64a2151 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# xcode +*.pbxuser +*.perspective +*.perspectivev3 +*.mode1v3 +*.mode2v3 +xcuserdata +build/* +DerivedData/ +# *.xcworkspace/ + +# osx +.DS_Store + +# vscode +.vscode/* + +# Bundler +.bundle + +# Carthage +Carthage + +# CocoaPods +Pods/ +Packages/*/Package.resolved + +# Environment +.env diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..8403bd3 --- /dev/null +++ b/.swiftformat @@ -0,0 +1,96 @@ +# MARK: - Indentation and Spacing +--indent 4 +--tab-width 4 +--max-width 120 +--wrap-arguments before-first +--wrap-parameters before-first +--wrap-collections before-first +--closing-paren balanced +--wrap-return-type never + +# MARK: - Line Breaks and Spacing +--linebreaks lf +--trim-whitespace always +--trailing-commas never +--semicolons never +--operator-func spaced +--no-space-operators ...,..< +--ranges spaced + +# MARK: - Braces and Brackets +--allman false +--else-position same-line +--guard-else auto +--ifdef indent +--indent-case false +--indent-strings false +--xcode-indentation enabled + +# MARK: - Imports and Headers +--import-grouping testable-last +--mark-extensions always +--mark-types always +--extension-acl on-extension +--property-types inferred +--nil-init remove + +# MARK: - Function and Method Formatting +--func-attributes preserve +--type-attributes preserve +--stored-var-attributes preserve +--computed-var-attributes preserve +--pattern-let hoist +--strip-unused-args closure-only +--self remove +--self-required UIViewController,UIView,NSViewController,NSView + +# MARK: - Comments and Documentation +--doc-comments before-declarations +--header strip +--ifdef indent + +# MARK: - SwiftUI and Modern Swift Features +--swift-version 6.0 +--sort-swiftui-properties first-appearance-sort +--some-any true +--short-optionals always + +# MARK: - Code Organization +--organize-types class,actor,struct,enum,extension,protocol +--organization-mode visibility +--class-threshold 0 +--struct-threshold 0 +--enum-threshold 0 +--extension-threshold 0 + +# MARK: - Blank Lines and Spacing +# --disable blankLinesAtStartOfScope +--enable blankLinesAtStartOfScope +--disable blankLinesAtEndOfScope +--type-blank-lines insert + +# MARK: - Exclusions +--exclude "**/.build/**" +--exclude "**/DerivedData/**" +--exclude "**/Pods/**" +--exclude "**/Carthage/**" +--exclude "**/.git/**" +--exclude "**/node_modules/**" + +# MARK: - File Patterns +--symlinks ignore + +# MARK: - Enhanced Modern Swift +--acronyms auto + +# MARK: - Better Organization +--modifierorder override,public,internal,fileprivate,private,static,class,final,required,convenience,lazy,weak,unowned + +# MARK: - Improved Consistency +--wrap-ternary before-operators + +# MARK: - Additional Rules +--enable wrapMultilineStatementBraces +--enable preferKeyPath +--enable sortSwitchCases +--enable blankLinesBetweenImports diff --git a/Documentation/ADR/ADR-001-modular-architecture-with-spm.md b/Documentation/ADR/ADR-001-modular-architecture-with-spm.md new file mode 100644 index 0000000..b658ce5 --- /dev/null +++ b/Documentation/ADR/ADR-001-modular-architecture-with-spm.md @@ -0,0 +1,59 @@ +# ADR-001: Modular Architecture with Swift Package Manager + +**Status:** Accepted + +**Date:** 2025-01-11 + +**Context:** + +When designing the OMG application architecture, I wanted to establish a solid foundation that would support long-term maintainability and growth. I evaluated several approaches for structuring the codebase: + +- **Workspace with multiple Xcode targets:** Traditional approach but requires manual dependency management +- **CocoaPods/Carthage frameworks:** Third-party dependency managers with additional tooling overhead +- **Swift Package Manager (SPM):** Native Swift solution with first-class Xcode integration + +**Decision:** + +I chose Swift Package Manager as the foundation for a modular architecture, organizing the codebase into discrete packages within a `Packages/` directory from the outset. Each package represents either a feature domain (Auth, Status, Account, etc.) or infrastructure concern (OMGAPI, DesignSystem, SessionService, etc.). + +Key principles of this SPM-based architecture: + +1. **Local packages:** All packages reside in `Packages/` directory within the repository, not as external dependencies +2. **Explicit dependencies:** Each `Package.swift` declares its dependencies, making relationships clear and enforceable +3. **Target-based layering:** Within packages, I use multiple targets (main module, Service, Repository, NetworkService, PersistenceService) to enforce layer boundaries +4. **Public API surfaces:** Packages expose only necessary APIs; internal implementation details remain private +5. **Independent testing:** Each package has its own test target, enabling isolated unit testing +6. **Shared utilities first:** Foundation packages (FoundationExtensions, Utilities) have no feature dependencies + +**Consequences:** + +### Positive + +- **Faster incremental builds:** Xcode only rebuilds changed packages and their dependents +- **Clear dependency graph:** SPM enforces acyclic dependencies at compile time, preventing circular references +- **Better code organization:** Related code lives together in cohesive packages with clear purposes +- **Improved testability:** Individual packages can be tested in isolation without application overhead +- **Enforced architecture:** Package boundaries make it impossible to violate layering rules without explicit dependency changes +- **Native tooling:** SPM is built into Xcode and Swift, requiring no additional setup +- **Scalability:** The architecture naturally accommodates growth without major restructuring + +### Negative + +- **Initial setup overhead:** Creating package structure requires upfront planning before writing feature code +- **Xcode scheme proliferation:** Each package and target creates additional schemes (mitigated by hiding unnecessary schemes) +- **Cross-package refactoring:** Moving code between packages requires updating multiple `Package.swift` files +- **Build system quirks:** Occasional Xcode caching issues require clean builds or derived data deletion + +### Neutral + +- **Package granularity decisions:** Ongoing judgment calls about when to split or merge packages +- **Version management:** All packages version together with the main app (not independent versioning) + +**Related Decisions:** + +- [ADR-002: Layered Architecture and Dependency Direction](ADR-002-layered-architecture-and-dependency-direction.md) - Defines how packages relate to each other +- [ADR-003: Feature-Based Package Organization](ADR-003-feature-based-package-organization.md) - Details package internal structure + +**Notes:** + +This modular approach was chosen from the beginning to establish clear boundaries and maintainability patterns from day one. diff --git a/Documentation/ADR/ADR-002-layered-architecture-and-dependency-direction.md b/Documentation/ADR/ADR-002-layered-architecture-and-dependency-direction.md new file mode 100644 index 0000000..194a2c6 --- /dev/null +++ b/Documentation/ADR/ADR-002-layered-architecture-and-dependency-direction.md @@ -0,0 +1,89 @@ +# ADR-002: Layered Architecture and Dependency Direction + +**Status:** Accepted + +**Date:** 2025-01-11 + +**Context:** + +When designing the OMG application, I wanted to establish clear architectural boundaries that would prevent common issues like tight coupling, circular dependencies, and difficulty testing. I needed a structure that would: + +1. Separate concerns cleanly (UI, business logic, data access) +2. Make dependencies explicit and enforceable +3. Enable testing at each layer in isolation +4. Prevent accidental violations of architectural rules + +**Decision:** + +I adopted a strict layered architecture with enforced one-way dependency flow. The layers are: + +``` + Views + ↓ + View Models + ↓ + Repositories + ↓ + Network & Persistence Services + ↓ + Shared Services (OMGAPI, SessionService, DesignSystem) + ↓ +Foundation Modules (FoundationExtensions, Utilities) +``` + +**Dependency Rules (Enforced):** + +1. **UI → Repository (NOT → Services):** Views and ViewModels depend on Repository protocols, never directly on NetworkService or PersistenceService +2. **Repository → Services:** Repositories coordinate between NetworkService and PersistenceService layers +3. **Services → Shared Utilities:** Services depend only on infrastructure (OMGAPI, SessionService) and utilities +4. **No upward dependencies:** Lower layers cannot import higher layers +5. **Cross-cutting packages independent:** Infrastructure packages (DesignSystem, OMGAPI) do not depend on features + +**Layer Responsibilities:** + +- **Views (SwiftUI):** Presentation logic only, delegating actions to ViewModels +- **ViewModels (@Observable):** UI state management, coordinating user actions via Repository protocols +- **Repositories:** Domain logic, caching strategies, data coordination between network and persistence +- **NetworkService:** API communication, mapping remote payloads to DTOs (OMGAPI models) +- **PersistenceService:** Swift Data storage, local data management with domain or DTO representations +- **Shared Infrastructure:** Cross-cutting concerns (HTTP client, session management, UI components) +- **Foundation Modules:** Pure utilities with no domain knowledge + +**How Dependencies are Enforced:** + +SPM package dependencies in `Package.swift` files make violations impossible at compile time. For example: +- A View's package can depend on Repository but not NetworkService +- NetworkService cannot import Repository (would be rejected by SPM) +- Repositories are often `actor` types, ensuring thread-safe data operations + +**Consequences:** + +### Positive + +- **Testability:** Each layer can be tested independently using protocol mocks/stubs +- **Compile-time safety:** Architectural violations are caught by the Swift compiler +- **Clear responsibilities:** Each layer has a well-defined purpose +- **Flexibility:** Implementations can be swapped without affecting higher layers (e.g., switching persistence strategies) +- **Reasoning:** Easy to understand where code belongs and how data flows +- **Concurrency safety:** Actor isolation at repository layer prevents data races + +### Negative + +- **Initial complexity:** New features require thinking about multiple layers +- **Boilerplate:** Protocol definitions and implementations add code volume +- **Cross-layer changes:** Some features require touching multiple layers sequentially + +### Neutral + +- **Layer granularity:** Ongoing decisions about when to introduce intermediate layers (e.g., Service layer between Repository and UI) + +**Related Decisions:** + +- [ADR-001: Modular Architecture with Swift Package Manager](ADR-001-modular-architecture-with-spm.md) - SPM enables enforcement +- [ADR-003: Feature-Based Package Organization](ADR-003-feature-based-package-organization.md) - How layers map to package targets +- [ADR-009: Protocol-First Repository and Service Boundaries](../Patterns/ADR-009-protocol-first-boundaries.md) - Testing and abstraction approach +- [ADR-012: DTO-Based Data Flow](../Data-Flow/ADR-012-dto-based-data-flow.md) - How data transforms across layers + +**Notes:** + +This strict layering was designed from the start to prevent architectural erosion over time. The one-way dependency flow ensures the codebase remains maintainable as it grows. diff --git a/Documentation/ADR/ADR-003-feature-based-package-organization.md b/Documentation/ADR/ADR-003-feature-based-package-organization.md new file mode 100644 index 0000000..0f34caa --- /dev/null +++ b/Documentation/ADR/ADR-003-feature-based-package-organization.md @@ -0,0 +1,98 @@ +# ADR-003: Feature-Based Package Organization + +**Status:** Accepted + +**Date:** 2025-01-11 + +**Context:** + +With a modular architecture established via Swift Package Manager, I needed to define how to organize code within each package. The key questions were: + +1. What constitutes a "feature" worthy of its own package? +2. How should layers be represented within a feature package? +3. What should be public vs. private in each package? +4. How do features integrate with the main application? + +**Decision:** + +I adopted a feature-based package organization where each domain feature (Auth, Status, Account, PURLs, Pastebin, etc.) is a separate package with multiple internal targets representing architectural layers. + +**Package Structure Pattern:** + +``` +FeatureName/ +├── Package.swift # Dependencies and targets +├── Sources/ +│ ├── FeatureName/ # Main UI and app integration +│ │ ├── Views/ # All SwiftUI views +│ │ │ ├── App/ # Main feature view and view model +│ │ │ └── [Feature Areas]/ # Other views grouped by functionality +│ │ ├── Scenes/ # Private window scenes (WindowGroup) +│ │ ├── Factories/ # AppFactory with public integration methods +│ │ └── Environment/ # Feature environment and DI setup +│ ├── FeatureNameService/ # Business logic layer (optional) +│ ├── FeatureNameRepository/ # Data coordination +│ ├── FeatureNameNetworkService/ # API communication +│ └── FeatureNamePersistenceService/ # Swift Data storage +└── Tests/ # Unit tests for each layer +``` + +**Public API Surface:** + +Each feature package exposes a minimal public API through its `AppFactory`: + +1. `makeAppView()` - Creates the main feature view for display in the sidebar +2. `makeScene()` - Returns window scenes (private to the module) as `some Scene` +3. `makeSettingsView()` - Creates settings views (when applicable) + +Everything else (scenes, view models, repositories, services) remains internal to the package. + +**Feature Environment:** + +Each feature has its own `Environment` struct that: +- Manages a `MicroContainer` for dependency resolution +- Registers all layer factories (NetworkService, PersistenceService, Repository) +- Exposes `viewModelFactory` and `modelContainer` properties +- Is passed to scenes which use it to build views + +**Feature Categories:** + +- **Domain Features:** Auth, Account, Status, Now, PURLs, Webpage, Pastebin, Weblog, Pics +- **Infrastructure Packages:** OMGAPI, DesignSystem, SessionService, AuthSession, Route, Sidebar +- **Utility Packages:** FoundationExtensions, Utilities + +**Integration with Main App:** + +The main application (`TritonApp`) uses `TritonEnvironment` to resolve all feature factories via MicroContainer. Scenes are now private within feature modules and receive the feature `Environment` to build views. + +**Consequences:** + +### Positive + +- **Feature isolation:** Each feature is self-contained with clear boundaries +- **Parallel development:** Features can evolve independently without conflicts +- **Selective testing:** Can test individual features without loading others +- **Clear ownership:** Each feature package owns its complete vertical slice +- **Reduced coupling:** Features cannot access each other's internals +- **Minimal public API:** Only integration points are exposed, reducing breaking changes + +### Negative + +- **Package proliferation:** Many packages to navigate (mitigated by clear naming) +- **Cross-feature sharing:** Shared functionality must be extracted to infrastructure packages +- **Discovery:** Finding code requires knowing which feature it belongs to + +### Neutral + +- **Feature granularity:** Judgment calls about feature boundaries (e.g., should PURLs and Status be one package?) +- **Shared UI components:** Decision about when to move components to DesignSystem vs. keeping them in features + +**Related Decisions:** + +- [ADR-001: Modular Architecture with Swift Package Manager](ADR-001-modular-architecture-with-spm.md) - Enables package-based organization +- [ADR-002: Layered Architecture and Dependency Direction](ADR-002-layered-architecture-and-dependency-direction.md) - Layers within packages +- [ADR-010: AppFactory Pattern for Feature Integration](../Patterns/ADR-010-appfactory-pattern.md) - How features expose themselves + +**Notes:** + +This structure was designed from the start to support clean feature boundaries and vertical slice architecture. Each package represents a complete feature with all its layers. diff --git a/Documentation/ADR/ADR-004-migration-from-combine-to-async-await.md b/Documentation/ADR/ADR-004-migration-from-combine-to-async-await.md new file mode 100644 index 0000000..acf42b5 --- /dev/null +++ b/Documentation/ADR/ADR-004-migration-from-combine-to-async-await.md @@ -0,0 +1,77 @@ +# ADR-004: Migration from Combine to Async/Await + +**Status:** Accepted + +**Date:** 2025-01-11 + +**Context:** + +Initially, the OMG application used Combine throughout for asynchronous operations in services and repositories. As the codebase evolved and Swift's concurrency model matured, I needed to decide whether to continue with Combine or migrate to async/await. + +Key considerations: + +1. **Language evolution:** Swift's native async/await became the recommended approach for asynchronous code +2. **Complexity:** Combine introduced complexity with operators, publishers, and subscriptions that weren't always necessary +3. **Learning curve:** async/await is more straightforward and easier to reason about +4. **Ecosystem shift:** Apple's frameworks increasingly favor async/await over Combine + +**Decision:** + +I migrated the codebase from Combine to async/await for services and repositories. The migration focused on: + +1. **Service Layer:** All repository and service methods use async/await for asynchronous operations +2. **Network calls:** API requests use async/await instead of Combine publishers +3. **Data operations:** Swift Data operations naturally work with async/await + +**Where Async/Await is Used:** + +- Repository protocols and implementations +- NetworkService methods +- PersistenceService data operations +- ViewModel methods that coordinate async operations +- Any I/O bound operations (network, disk, etc.) + +**Where Combine Still Existed After This Migration:** + +- UI layer (ViewModels) continued to use Combine with @Published properties and ObservableObject +- This was later migrated separately with the adoption of Swift's Observation framework (see ADR-005) + +**Consequences:** + +### Positive + +- **Simpler code:** Async/await reads sequentially, easier to understand than Combine chains +- **Better error handling:** Standard try/catch instead of Combine's error handling +- **Reduced boilerplate:** No need to manage Cancellables, subscriptions, or AnyCancellable sets +- **Native Swift:** Language-level support means better tooling and compiler support +- **Debugging:** Stack traces are clearer, easier to step through async code + +### Negative + +- **Migration effort:** Requires rewriting existing Combine-based code +- **Lost operators:** Some Combine operators (debounce, throttle) need manual implementation +- **Breaking changes:** Function signatures change from publishers to async throws + +### Neutral + +- **Different mental model:** Moving from reactive streams to sequential async code +- **Pattern changes:** Some reactive patterns need rethinking in async/await terms + +**Migration Strategy:** + +The migration was done incrementally: + +1. Started with repositories and services (deepest layers) +2. Updated repository methods to return async/await instead of publishers +3. ViewModels continued using Combine to consume these async methods +4. Removed Combine imports from service and repository layers +5. UI layer migration to Observation framework happened later (see ADR-005) + +**Related Decisions:** + +- [ADR-005: Adoption of Swift Observation Framework](ADR-005-adoption-of-swift-observation-framework.md) - Completed the migration away from Combine in UI layer +- [ADR-006: Swift Data over Core Data](ADR-006-swift-data-over-core-data.md) - Swift Data works naturally with async/await + +**Notes:** + +This migration aligns with Swift's evolution and Apple's recommended patterns. Async/await provides a cleaner, more maintainable foundation for asynchronous code in services and repositories. The UI layer's transition away from Combine happened separately with the Observation framework adoption. diff --git a/Documentation/ADR/ADR-005-adoption-of-swift-observation-framework.md b/Documentation/ADR/ADR-005-adoption-of-swift-observation-framework.md new file mode 100644 index 0000000..1035b78 --- /dev/null +++ b/Documentation/ADR/ADR-005-adoption-of-swift-observation-framework.md @@ -0,0 +1,115 @@ +# ADR-005: Adoption of Swift Observation Framework + +**Status:** Accepted + +**Date:** 2025-01-11 + +**Context:** + +After migrating services and repositories from Combine to async/await (ADR-004), the UI layer still used Combine's ObservableObject protocol with @Published properties for view-viewmodel binding. Apple introduced the Observation framework with the @Observable macro as a modern replacement. + +Key considerations: + +1. **Simpler syntax:** @Observable eliminates boilerplate (@Published, objectWillChange) +2. **Better performance:** Fine-grained observation tracks only accessed properties +3. **Reduced dependencies:** No need for Combine in view models +4. **Language-level support:** @Observable is a Swift macro, not a framework feature +5. **SwiftUI integration:** Native support in SwiftUI for @Observable types + +**Decision:** + +I migrated all view models from ObservableObject + @Published to @Observable. This completed the removal of Combine from the codebase. + +**Migration Pattern:** + +**Before (Combine):** +```swift +final class StatusViewModel: ObservableObject { + @Published var statuses: [Status] = [] + @Published var isLoading = false + + private var cancellables = Set() + + func loadStatuses() { + isLoading = true + repository.fetchStatuses() + .sink { [weak self] completion in + self?.isLoading = false + } receiveValue: { [weak self] statuses in + self?.statuses = statuses + } + .store(in: &cancellables) + } +} +``` + +**After (Observation):** +```swift +@Observable +final class StatusViewModel { + var statuses: [Status] = [] + var isLoading = false + + func loadStatuses() async { + isLoading = true + defer { isLoading = false } + + do { + statuses = try await repository.fetchStatuses() + } catch { + // handle error + } + } +} +``` + +**Use of @ObservationIgnored:** + +Properties marked with @ObservationIgnored don't trigger view updates: +- Dependencies (repositories, services) injected via initializer +- Computed properties +- Internal state that doesn't affect UI +- Constants and configuration + +**Consequences:** + +### Positive + +- **Less boilerplate:** No @Published, no Combine imports, no AnyCancellable storage +- **Better performance:** SwiftUI only observes accessed properties, not the entire object +- **Clearer code:** Direct property access instead of publisher chains +- **Natural async integration:** Works seamlessly with async/await in repositories +- **Compile-time safety:** @Observable provides type-safe observation +- **Removed Combine dependency:** UI layer no longer needs Combine framework + +### Negative + +- **Migration effort:** Requires rewriting all view models +- **Learning curve:** Understanding when to use @ObservationIgnored +- **Breaking changes:** View models are no longer ObservableObject instances + +### Neutral + +- **Different patterns:** Some Combine patterns (like debouncing) need alternative implementations +- **Observation scope:** Need to understand observation tracking to avoid over-observation + +**Migration Strategy:** + +The migration was done incrementally: + +1. Started with simpler view models with fewer properties +2. Replaced ObservableObject conformance with @Observable macro +3. Removed @Published from properties +4. Converted Combine publisher chains to async/await calls +5. Removed AnyCancellable storage +6. Added @ObservationIgnored to dependencies and computed properties +7. Updated views to work with @Observable (mostly automatic) + +**Related Decisions:** + +- [ADR-004: Migration from Combine to Async/Await](ADR-004-migration-from-combine-to-async-await.md) - Repositories already using async/await enabled smooth integration +- [ADR-002: Layered Architecture and Dependency Direction](ADR-002-layered-architecture-and-dependency-direction.md) - ViewModels remain in UI layer with same responsibilities + +**Notes:** + +This migration completed the transition away from Combine throughout the entire codebase. The Observation framework provides a modern, performant, and simpler approach to reactive UI updates in SwiftUI. diff --git a/Documentation/ADR/ADR-006-swift-data-over-core-data.md b/Documentation/ADR/ADR-006-swift-data-over-core-data.md new file mode 100644 index 0000000..8b26fce --- /dev/null +++ b/Documentation/ADR/ADR-006-swift-data-over-core-data.md @@ -0,0 +1,110 @@ +# ADR-006: Swift Data over Core Data + +**Status:** Accepted + +**Date:** 2025-01-11 + +**Context:** + +The OMG application initially used Core Data for persistence. However, as the application evolved, I realized that for a simple application with straightforward data models, Core Data's boilerplate was excessive: + +- `.xcdatamodeld` files and data model editors +- `NSManagedObject` subclasses with Objective-C runtime requirements +- `NSFetchRequest` with string-based predicates +- Context management and merging complexities +- Boilerplate that outweighed the actual business logic + +When Apple introduced Swift Data, it offered a modern alternative that matched the application's needs without the overhead. + +**Decision:** + +I migrated from Core Data to Swift Data for all persistence in the OMG application. The simpler API and reduced boilerplate were a better fit for this application's straightforward data models. + +**Key Implementation Patterns:** + +1. **@Model macro:** Domain models or persistence models marked with @Model +2. **ModelContainer:** Each feature's environment manages its own ModelContainer +3. **ModelContext:** Used in PersistenceService implementations for CRUD operations +4. **Actor isolation:** PersistenceService implementations often use `actor` for thread safety +5. **Query patterns:** Repository layer queries through PersistenceService protocols + +**Example Structure:** + +```swift +// Model definition +@Model +final class StatusEntity { + var id: String + var content: String + var createdAt: Date + + init(id: String, content: String, createdAt: Date) { + self.id = id + self.content = content + self.createdAt = createdAt + } +} + +// PersistenceService usage +actor StatusPersistenceService { + private let modelContext: ModelContext + + func fetchStatuses() async throws -> [StatusEntity] { + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.createdAt, order: .reverse)] + ) + return try modelContext.fetch(descriptor) + } +} +``` + +**Consequences:** + +### Positive + +- **Dramatically less boilerplate:** No .xcdatamodeld files, NSEntityDescription, or NSManagedObject subclasses +- **Type safety:** Compile-time checking instead of runtime string-based queries +- **Modern Swift:** Uses macros, property wrappers, and Swift's type system +- **Natural async:** Swift Data works seamlessly with async/await +- **Simpler relationships:** Relationships defined as regular Swift properties +- **Better fit for simple models:** Complexity matches actual needs +- **Predictable behavior:** Clearer mental model than Core Data's context management + +### Negative + +- **Migration effort:** Required rewriting persistence layer and data models +- **Data migration:** Had to handle Core Data to Swift Data data migration +- **Maturity:** Swift Data is newer with fewer resources than Core Data +- **Feature parity:** Some advanced Core Data features not available (not needed for this app) + +### Neutral + +- **Learning investment:** Different from Core Data, but simpler overall +- **Platform requirements:** Requires recent OS versions (acceptable tradeoff) + +**Migration Strategy:** + +The migration from Core Data to Swift Data involved: + +1. Redefining models using @Model macro instead of NSManagedObject +2. Replacing NSFetchRequest with FetchDescriptor +3. Converting NSPredicate string-based queries to Swift-native predicates +4. Updating PersistenceService implementations to use ModelContext +5. Migrating existing Core Data stores to Swift Data (if needed) +6. Removing .xcdatamodeld files and Core Data stack setup + +**Integration with Architecture:** + +- **PersistenceService layer:** Each feature has a PersistenceService target that uses Swift Data +- **Repository coordination:** Repositories coordinate between NetworkService and PersistenceService +- **ModelContainer management:** Feature environments expose modelContainer for dependency injection +- **Actor isolation:** PersistenceService implementations use `actor` for safe concurrent access + +**Related Decisions:** + +- [ADR-002: Layered Architecture and Dependency Direction](ADR-002-layered-architecture-and-dependency-direction.md) - Swift Data lives in PersistenceService layer +- [ADR-004: Migration from Combine to Async/Await](ADR-004-migration-from-combine-to-async-await.md) - Swift Data naturally supports async/await + +**Notes:** + +For a simple application with straightforward data models, Swift Data's reduced boilerplate was a significant improvement. The migration was worthwhile to eliminate Core Data's complexity that wasn't justified by the application's actual needs. diff --git a/Documentation/ADR/ADR-007-microclient-for-http-communication.md b/Documentation/ADR/ADR-007-microclient-for-http-communication.md new file mode 100644 index 0000000..de83aa9 --- /dev/null +++ b/Documentation/ADR/ADR-007-microclient-for-http-communication.md @@ -0,0 +1,97 @@ +# ADR-007: MicroClient for HTTP Communication + +**Status:** Accepted + +**Date:** 2025-01-11 + +**Context:** + +When designing the networking layer for the OMG application, I needed to choose an HTTP client library. The options included: + +1. **URLSession directly:** Apple's native networking, but requires significant boilerplate +2. **Alamofire:** Popular third-party library with extensive features +3. **MicroClient:** Lightweight Swift-first HTTP client I built on top of URLSession + +Key considerations: + +- **Simplicity:** Needed clean API for straightforward HTTP requests +- **Modern Swift:** Should work naturally with async/await +- **Type safety:** Compile-time safety for requests and responses +- **Control:** Ability to customize exactly for this application's needs +- **Boilerplate reduction:** Less code than raw URLSession + +**Decision:** + +I chose to build and use MicroClient as the HTTP communication library for the OMG application. It provides a clean, type-safe API built on top of URLSession without the complexity of larger networking frameworks, tailored specifically to my needs. + +**Key Implementation Patterns:** + +1. **OMGAPIFactory:** Central factory for creating HTTP requests using MicroClient's `NetworkRequest` +2. **Request/Response models:** DTOs defined in OMGAPI package +3. **NetworkService layer:** Each feature's NetworkService uses MicroClient +4. **Async/await:** All network calls use async/await for clean asynchronous code +5. **Interceptors:** Authentication and common headers handled via MicroClient interceptors + +**Example Usage:** + +```swift +// In OMGAPIFactory - creates typed requests +extension OMGAPIFactory { + static func makeAllStatusesRequest() -> NetworkRequest { + .init( + path: "/statuslog", + method: .get + ) + } +} + +// In NetworkService - executes requests +actor StatusNetworkService { + private let client: MicroClient + + func fetchStatuses() async throws -> StatuslogResponse { + let request = OMGAPIFactory.makeAllStatusesRequest() + return try await client.run(request) + } +} +``` + +**Consequences:** + +### Positive + +- **Clean API:** Simpler than raw URLSession, focused on actual needs +- **Type safety:** Request and response types checked at compile time via `NetworkRequest` +- **Async/await native:** Built for modern Swift concurrency +- **Lightweight:** Minimal dependency footprint +- **Full control:** Can evolve the library alongside the application +- **Tailored design:** API shaped exactly for this application's patterns +- **Flexible:** Can add interceptors for auth, logging, etc. + +### Negative + +- **Maintenance responsibility:** I maintain the library +- **Documentation:** Need to document patterns for my future reference +- **External use consideration:** Library evolved alongside this specific application + +### Neutral + +- **Dependency management:** External dependency, but under my control +- **Feature scope:** Only implements features needed by this application + +**Integration with Architecture:** + +- **OMGAPI package:** Contains OMGAPIFactory and all request/response DTOs +- **NetworkService layer:** Each feature's NetworkService depends on OMGAPI and uses MicroClient +- **Repository coordination:** Repositories consume NetworkService protocols +- **Error handling:** Network errors handled at repository boundary, mapped to domain errors + +**Related Decisions:** + +- [ADR-002: Layered Architecture and Dependency Direction](ADR-002-layered-architecture-and-dependency-direction.md) - MicroClient used in NetworkService layer +- [ADR-004: Migration from Combine to Async/Await](ADR-004-migration-from-combine-to-async-await.md) - MicroClient built with async/await support +- [ADR-012: DTO-Based Data Flow](../Data-Flow/ADR-012-dto-based-data-flow.md) - Request/response models in OMGAPI + +**Notes:** + +Building MicroClient gave me full control over the networking layer's API design and allowed me to create a library that fits perfectly with the application's architecture and patterns. The `NetworkRequest` type provides compile-time safety for API calls. diff --git a/Documentation/ADR/ADR-008-microcontainer-for-dependency-injection.md b/Documentation/ADR/ADR-008-microcontainer-for-dependency-injection.md new file mode 100644 index 0000000..9ff1487 --- /dev/null +++ b/Documentation/ADR/ADR-008-microcontainer-for-dependency-injection.md @@ -0,0 +1,125 @@ +# ADR-008: MicroContainer for Dependency Injection + +**Status:** Accepted + +**Date:** 2025-01-11 + +**Context:** + +With a modular architecture using multiple packages, I needed a dependency injection solution to wire up the application. The options included: + +1. **Manual dependency injection:** Pass dependencies through initializers (simple but verbose) +2. **SwiftUI @Environment:** Good for view-level DI but limited to SwiftUI context +3. **Third-party DI frameworks:** Swinject, Resolver, etc. (feature-rich but complex) +4. **MicroContainer:** Lightweight DI container I built for this purpose + +Key considerations: + +- **Simplicity:** Needed straightforward registration and resolution +- **Type safety:** Compile-time safety where possible +- **Factory pattern support:** Factories are the primary integration pattern +- **Lifecycle management:** Support for singleton (.static) vs transient instances +- **Minimal overhead:** Lightweight solution without complex features + +**Decision:** + +I chose to build and use MicroContainer (exposed as `DependencyContainer`) as the dependency injection solution for the OMG application. It provides a simple, type-safe API for registering and resolving dependencies, tailored to the application's factory-based architecture. + +**Key Implementation Patterns:** + +1. **TritonEnvironment:** Main app environment manages root `DependencyContainer` +2. **Registration:** Dependencies registered with type, allocation strategy, and factory closure +3. **Resolution:** Simple `container.resolve()` with type inference +4. **Static allocation:** Singleton instances using `.static` allocation +5. **Dependency chains:** Container passed to factory closures for resolving dependencies + +**Example Usage:** + +```swift +// Main environment setup +struct TritonEnvironment: TritonEnvironmentProtocol { + private let container = DependencyContainer() + + // Computed properties resolve from container + var authSessionService: any AuthSessionServiceProtocol { container.resolve() } + var statusAppFactory: StatusAppFactory { container.resolve() } + + init() { + // Register core service with singleton allocation + container.register( + type: (any AuthSessionServiceProtocol).self, + allocation: .static + ) { _ in + authSessionServiceFactory.makeAuthSessionService() + } + + // Register with dependency resolution + container.register( + type: (any NetworkClientProtocol).self, + allocation: .static + ) { container in + let authSessionService = container.resolve() as any AuthSessionServiceProtocol + return networkClient.makeOMGAPIClient( + authTokenProvider: { + await authSessionService.accessToken + } + ) + } + + // Register feature factory + container.register( + type: StatusAppFactory.self, + allocation: .static + ) { container in + StatusAppFactory( + sessionService: container.resolve(), + authSessionService: container.resolve(), + networkClient: container.resolve() + ) + } + } +} +``` + +**Consequences:** + +### Positive + +- **Simple API:** Easy to understand registration and resolution +- **Type safety:** Type-based resolution with compiler support +- **Lightweight:** Minimal overhead and complexity +- **Full control:** Can evolve alongside the application's needs +- **Factory-friendly:** Natural fit for factory-based architecture +- **Allocation control:** Explicit `.static` for singletons +- **Testability:** Easy to register mock implementations for testing + +### Negative + +- **Maintenance responsibility:** I maintain the library +- **Limited features:** No advanced DI features (property injection, circular dependency detection, etc.) +- **Manual registration:** Need to explicitly register all dependencies +- **No automatic cleanup:** Need to manage lifecycles manually + +### Neutral + +- **Learning curve:** Need to understand container patterns +- **Feature scope:** Only implements features needed by this application +- **Dependency management:** External dependency, but under my control + +**Integration with Architecture:** + +- **TritonEnvironment:** Root environment in main app with `DependencyContainer` +- **Registration pattern:** Init methods register all dependencies with allocation strategy +- **Resolution pattern:** Computed properties use `container.resolve()` for lazy access +- **Factory dependencies:** Factories receive resolved dependencies via initializers +- **Testing:** Test environments can register mock implementations + +**Related Decisions:** + +- [ADR-001: Modular Architecture with Swift Package Manager](ADR-001-modular-architecture-with-spm.md) - Container wires together modular packages +- [ADR-003: Feature-Based Package Organization](ADR-003-feature-based-package-organization.md) - Each feature's AppFactory resolved from container +- [ADR-010: AppFactory Pattern for Feature Integration](../Patterns/ADR-010-appfactory-pattern.md) - Factories use container for dependency resolution + +**Notes:** + +Building MicroContainer gave me a dependency injection solution that fits perfectly with the factory-based architecture. The `DependencyContainer` provides just enough DI functionality with explicit allocation control and type-safe resolution. diff --git a/Documentation/ADR/ADR-009-protocol-first-repository-and-service-boundaries.md b/Documentation/ADR/ADR-009-protocol-first-repository-and-service-boundaries.md new file mode 100644 index 0000000..341415a --- /dev/null +++ b/Documentation/ADR/ADR-009-protocol-first-repository-and-service-boundaries.md @@ -0,0 +1,172 @@ +# ADR-009: Protocol-First Repository and Service Boundaries + +**Status:** Accepted + +**Date:** 2025-01-11 + +**Context:** + +When designing the architecture for repositories and services, I needed to decide how to define their boundaries and contracts. Key considerations included: + +1. **Abstraction:** Enable testing and flexibility without tight coupling to implementations +2. **Documentation:** Clear contracts for what each layer provides +3. **Dependency injection:** Support for swapping implementations +4. **Type safety:** Compile-time guarantees about capabilities +5. **Sendable conformance:** Support for Swift concurrency and actor isolation + +The options were: +- **Concrete types only:** Simple but inflexible, hard to test +- **Protocols for everything:** Maximum flexibility but adds boilerplate +- **Protocol-first boundaries:** Protocols at layer boundaries, concrete types internally + +**Decision:** + +I adopted a protocol-first approach for repositories and services, where: + +1. **Public protocols define contracts** - Each repository and service has a public protocol +2. **Concrete implementations are internal/actor-based** - Implementations use `actor` for thread safety +3. **Only protocols are documented** - DocC documentation focuses on public protocol contracts +4. **Protocols are Sendable** - Enable safe usage across actor boundaries +5. **Implementations remain undocumented** - Internal details don't need public documentation + +**Implementation Pattern:** + +```swift +/// Public protocol with comprehensive documentation +public protocol PURLsNetworkServiceProtocol: AnyObject, Sendable { + /// Provides a stream of PURL collection updates. + /// + /// This method returns an AsyncStream that emits arrays of `PURLResponse` objects + /// whenever PURL collections are fetched or modified. This enables reactive UI updates + /// and real-time synchronization between network operations and local storage. + /// + /// - Returns: An `AsyncStream<[PURLResponse]>` that emits PURL collection updates. + func purlsStream() -> AsyncStream<[PURLResponse]> + + /// Fetches all PURLs for a specific address and emits them through the stream. + /// + /// - Parameter address: The address to fetch PURLs for. + /// - Throws: Network errors, API errors, or decoding errors if the fetch operation fails. + func fetchPURLs(for address: String) async throws + + func addPURL(address: String, name: String, url: String) async throws + func deletePURL(address: String, name: String) async throws +} + +/// Actor implementation without public documentation +actor PURLsNetworkService: PURLsNetworkServiceProtocol { + private let networkClient: NetworkClientProtocol + private let purlsStreamContinuation: AsyncStream<[PURLResponse]>.Continuation + + init(networkClient: NetworkClientProtocol) { + self.networkClient = networkClient + // Implementation details... + } + + func fetchPURLs(for address: String) async throws { + // Implementation... + } + + // Other methods... +} +``` + +**Protocol Naming Convention:** + +- Repositories: `FeatureNameRepositoryProtocol` (e.g., `PURLsRepositoryProtocol`) +- Network Services: `FeatureNameNetworkServiceProtocol` (e.g., `PURLsNetworkServiceProtocol`) +- Persistence Services: `FeatureNamePersistenceServiceProtocol` +- Implementations drop the `Protocol` suffix (e.g., `PURLsRepository`) + +**Sendable Conformance:** + +Protocols marked as `Sendable` to enable: +- Safe passing across actor boundaries +- Usage in concurrent contexts +- Compliance with Swift 6 strict concurrency + +```swift +public protocol PURLsRepositoryProtocol: Sendable { + // Methods... +} +``` + +**Documentation Strategy:** + +1. **Protocols:** Fully documented with DocC comments + - What the method does + - Parameters and return values + - Error conditions + - Usage examples when helpful + +2. **Implementations:** Minimal or no documentation + - Implementation details are internal + - Comments only for complex internal logic + - Focus on maintainability, not external API + +**Why Not Document Implementations:** + +- Implementations are `actor` or internal types, not part of public API +- Keeps documentation focused on contracts, not implementation details +- Reduces maintenance burden when implementations change +- Protocol documentation is the source of truth + +**Consequences:** + +### Positive + +- **Testability:** Easy to create mock implementations for testing +- **Flexibility:** Can swap implementations without affecting consumers +- **Clear contracts:** Protocol defines exactly what each layer provides +- **Documentation clarity:** Protocol docs are single source of truth +- **Type safety:** Compiler enforces protocol conformance +- **Sendable safety:** Protocols ensure safe concurrent usage +- **Reduced maintenance:** Don't need to keep implementation docs in sync + +### Negative + +- **Boilerplate:** Need to define both protocol and implementation +- **Indirection:** One extra layer between usage and implementation +- **Protocol proliferation:** Many protocols throughout the codebase + +### Neutral + +- **Naming conventions:** Need consistent protocol naming patterns +- **Protocol granularity:** Ongoing decisions about protocol size and scope + +**Integration with Architecture:** + +- **Repository layer:** Repositories depend on service protocols, not implementations +- **Dependency injection:** Factories register protocol types in DependencyContainer +- **View models:** Depend on repository protocols +- **Testing:** Mock objects conform to protocols + +```swift +// Repository depends on service protocols +actor PURLsRepository: PURLsRepositoryProtocol { + private let networkService: any PURLsNetworkServiceProtocol + private let persistenceService: any PURLsPersistenceServiceProtocol + // ... +} + +// Registered by protocol type +container.register( + type: (any PURLsRepositoryProtocol).self, + allocation: .static +) { container in + PURLsRepository( + networkService: container.resolve(), + persistenceService: container.resolve() + ) +} +``` + +**Related Decisions:** + +- [ADR-002: Layered Architecture and Dependency Direction](ADR-002-layered-architecture-and-dependency-direction.md) - Protocols define layer boundaries +- [ADR-008: MicroContainer for Dependency Injection](ADR-008-microcontainer-for-dependency-injection.md) - Protocols registered in container +- [ADR-011: Actor Isolation for Repository and Service Concurrency](ADR-011-actor-isolation-for-repository-concurrency.md) - Implementations are actors + +**Notes:** + +The protocol-first approach strikes the right balance between abstraction and simplicity. By documenting only protocols, I maintain clear contracts while keeping implementation details internal and flexible. The Sendable conformance ensures protocols work seamlessly with Swift's actor-based concurrency model. diff --git a/Documentation/ADR/ADR-010-appfactory-pattern-for-feature-integration.md b/Documentation/ADR/ADR-010-appfactory-pattern-for-feature-integration.md new file mode 100644 index 0000000..6efe4f1 --- /dev/null +++ b/Documentation/ADR/ADR-010-appfactory-pattern-for-feature-integration.md @@ -0,0 +1,141 @@ +# ADR-010: AppFactory Pattern for Feature Integration + +**Status:** Accepted + +**Date:** 2025-01-11 + +**Context:** + +With a modular architecture where each feature is a separate package, I needed a consistent way to integrate features into the main application. The key challenges were: + +1. **Encapsulation:** Feature internals (view models, repositories, services) should remain private +2. **Dependency injection:** Features need various dependencies from the main app +3. **Multiple entry points:** Features may provide main views, scenes, and settings views +4. **SwiftUI integration:** Need to provide both views and scenes (WindowGroup, etc.) +5. **Testability:** Integration points should be mockable + +**Decision:** + +I adopted the AppFactory pattern where each feature package exposes a single public factory class with three standardized methods: + +1. **`makeAppView()`** - Creates the main feature view for display in the sidebar or main content area +2. **`makeScene()`** - Returns window scenes (WindowGroup) private to the module +3. **`makeSettingsView()`** - Creates settings views when applicable (optional) + +Factories receive dependencies via their initializer and internally manage a feature-specific Environment that handles further dependency resolution. + +**Implementation Pattern:** + +```swift +public final class StatusAppFactory { + private let environment: StatusEnvironment + + public init( + sessionService: any SessionServiceProtocol, + authSessionService: any AuthSessionServiceProtocol, + networkClient: NetworkClientProtocol + ) { + environment = .init( + sessionService: sessionService, + authSessionService: authSessionService, + networkClient: networkClient + ) + } + + @MainActor + @ViewBuilder + public func makeAppView() -> some View { + let viewModel = environment.viewModelFactory + .makeStatusAppViewModel() + + StatusApp(viewModel: viewModel) + .environment(\.viewModelFactory, environment.viewModelFactory) + .modelContainer(environment.modelContainer) + } + + @MainActor + @ViewBuilder + public func makeSettingsView() -> some View { + let viewModel = environment.viewModelFactory + .makeStatusSettingsViewModel() + + StatusSettingsView(viewModel: viewModel) + .modelContainer(environment.modelContainer) + } + + @MainActor + public func makeScene() -> some Scene { + ComposeStatusScene(environment: environment) + } +} +``` + +**Integration with TritonEnvironment:** + +```swift +struct TritonEnvironment: TritonEnvironmentProtocol { + private let container = DependencyContainer() + + var statusAppFactory: StatusAppFactory { container.resolve() } + + init() { + container.register( + type: StatusAppFactory.self, + allocation: .static + ) { container in + StatusAppFactory( + sessionService: container.resolve(), + authSessionService: container.resolve(), + networkClient: container.resolve() + ) + } + } +} +``` + +**Why Factories Over Direct Instantiation:** + +1. **Encapsulation:** Keeps view models, repositories, and internal dependencies private +2. **Single public interface:** Only the factory is public, everything else is internal +3. **Dependency coordination:** Factory manages complex dependency graphs internally +4. **Consistent integration:** All features integrate the same way +5. **Testability:** Can mock factories for testing main app integration +6. **Lazy initialization:** Features only initialize when actually used + +**Consequences:** + +### Positive + +- **Clear boundaries:** Features expose minimal public API surface +- **Consistent pattern:** All features integrate identically +- **Dependency management:** Complex graphs handled internally by each feature +- **Flexibility:** Features can evolve internals without affecting main app +- **Testability:** Integration points are mockable +- **SwiftUI native:** Returns proper SwiftUI types (some View, some Scene) + +### Negative + +- **Boilerplate:** Each feature needs a factory class +- **Indirect access:** Can't directly instantiate feature components +- **Factory proliferation:** One factory per feature adds code + +### Neutral + +- **Learning curve:** Team members need to understand factory pattern +- **Factory responsibilities:** Ongoing decisions about what belongs in factory vs environment + +**Method Contracts:** + +- **`makeAppView()`** - REQUIRED: Must return main feature view, marked @MainActor +- **`makeScene()`** - OPTIONAL: Returns scenes for auxiliary windows, marked @MainActor +- **`makeSettingsView()`** - OPTIONAL: Returns settings view when feature has preferences, marked @MainActor + +**Related Decisions:** + +- [ADR-003: Feature-Based Package Organization](ADR-003-feature-based-package-organization.md) - Factories are part of feature structure +- [ADR-008: MicroContainer for Dependency Injection](ADR-008-microcontainer-for-dependency-injection.md) - Factories registered in container +- [ADR-002: Layered Architecture and Dependency Direction](ADR-002-layered-architecture-and-dependency-direction.md) - Factories are integration layer + +**Notes:** + +The AppFactory pattern provides a clean, consistent way to integrate modular features while maintaining strong encapsulation. It's the primary public interface for each feature package. diff --git a/Documentation/ADR/ADR-011-actor-isolation-for-repository-concurrency.md b/Documentation/ADR/ADR-011-actor-isolation-for-repository-concurrency.md new file mode 100644 index 0000000..b68c560 --- /dev/null +++ b/Documentation/ADR/ADR-011-actor-isolation-for-repository-concurrency.md @@ -0,0 +1,163 @@ +# ADR-011: Actor Isolation for Repository and Service Concurrency + +**Status:** Accepted + +**Date:** 2025-01-11 + +**Context:** + +With the adoption of async/await and Swift concurrency, I needed to decide how to handle thread safety and concurrent access in the service and repository layers. These layers coordinate async operations, manage streaming tasks, and may maintain internal state. + +Key considerations: + +1. **Thread safety:** Services and repositories may be accessed from multiple concurrent contexts +2. **Mutable state:** Streaming tasks, caches, and coordination state need protection +3. **Data races:** Swift 6 strict concurrency requires proper isolation +4. **Performance:** Don't want excessive synchronization overhead +5. **API ergonomics:** Should be easy to use with async/await + +The options were: +- **No isolation:** Unsafe, would lead to data races +- **Manual locking:** Error-prone and verbose (locks, queues) +- **@MainActor:** Simple but forces all operations to main thread +- **actor:** Automatic serialization with async/await integration + +**Decision:** + +I chose to implement services and repositories as `actor` types. Actors provide automatic serialization of mutable state access while maintaining clean async/await APIs. This ensures thread safety without manual synchronization code. + +**Implementation Pattern:** + +```swift +public protocol PURLsRepositoryProtocol: Sendable { + var purlsContainer: ModelContainer { get } + func fetchPURLs() async throws + func addPURL(address: String, name: String, url: String) async throws + func deletePURL(address: String, name: String) async throws +} + +actor PURLsRepository: PURLsRepositoryProtocol { + // Actor-isolated mutable state + private let networkService: any PURLsNetworkServiceProtocol + private let persistenceService: any PURLsPersistenceServiceProtocol + private var streamTask: Task? + + // Nonisolated for synchronous access to immutable/Sendable values + nonisolated var purlsContainer: ModelContainer { + persistenceService.container + } + + init( + networkService: any PURLsNetworkServiceProtocol, + persistenceService: PURLsPersistenceServiceProtocol, + authSessionService: any AuthSessionServiceProtocol, + sessionService: any SessionServiceProtocol + ) { + self.networkService = networkService + self.persistenceService = persistenceService + + Task { + await startPURLsSync() + } + } + + func fetchPURLs() async throws { + // Actor-isolated method, automatically serialized + try await networkService.fetchPURLs(for: current) + } + + private func startPURLsSync() { + // Manages mutable streamTask safely within actor + streamTask = Task { [weak self] in + guard let self else { return } + for await purls in networkService.purlsStream() { + // Process stream... + } + } + } +} +``` + +**When to Use `actor` vs `@MainActor`:** + +### Use `actor` for: +- **Repositories** - Coordinate async operations, manage streaming tasks, cache data +- **NetworkServices** - Handle network requests and response processing +- **PersistenceServices** - Manage Swift Data operations and storage +- **Background coordination** - Any service that doesn't need main thread +- **Mutable state** - Types that need to protect mutable internal state + +### Use `@MainActor` for: +- **ViewModels** - Need to update UI on main thread +- **View code** - SwiftUI views and their direct dependencies +- **UI coordination** - Types that only work with UI state + +### Don't isolate: +- **Pure protocols** - Protocol definitions should be Sendable, not isolated +- **Immutable types** - Structs with only immutable properties +- **Stateless utilities** - Pure functions and stateless helpers + +**Nonisolated Access:** + +Use `nonisolated` for properties that: +- Return Sendable values (ModelContainer, etc.) +- Are computed from actor-isolated state but safe to access directly +- Need synchronous access from outside the actor + +```swift +nonisolated var purlsContainer: ModelContainer { + persistenceService.container +} +``` + +**Data Safety Guarantees:** + +1. **Serialized access:** All actor methods execute serially, preventing data races +2. **Task management:** Internal tasks (streaming, background work) are safely managed +3. **State isolation:** Mutable state is protected within actor boundary +4. **Sendable conformance:** Protocols marked Sendable ensure safe cross-actor usage + +**Performance Considerations:** + +### Benefits: +- **Fine-grained concurrency:** Actors suspend rather than block +- **No lock overhead:** Runtime manages scheduling efficiently +- **Natural async:** Integrates seamlessly with async/await +- **Background execution:** Services and repositories don't block main thread + +### Tradeoffs: +- **Suspension points:** Each await on actor method is a potential suspension +- **Sequential execution:** Actor methods run serially (by design for safety) +- **Actor hopping:** Calls between different actors involve context switches + +**Consequences:** + +### Positive + +- **Automatic thread safety:** No manual locks or synchronization code +- **Data race prevention:** Compiler enforces safe concurrent access +- **Clean API:** Async/await usage feels natural +- **Mutable state protection:** Internal state safely managed +- **Swift 6 ready:** Strict concurrency checking passes +- **Off main thread:** Services and repositories don't block UI + +### Negative + +- **Learning curve:** Understanding actor isolation and suspension points +- **Performance characteristics:** Need to understand when suspension occurs +- **Debugging complexity:** Async context switches can complicate debugging + +### Neutral + +- **Actor reentrancy:** Need to be aware of reentrancy between suspension points +- **Sendable requirements:** Types crossing actor boundaries must be Sendable + +**Related Decisions:** + +- [ADR-004: Migration from Combine to Async/Await](ADR-004-migration-from-combine-to-async-await.md) - Actors work naturally with async/await +- [ADR-002: Layered Architecture and Dependency Direction](ADR-002-layered-architecture-and-dependency-direction.md) - Actor isolation at service and repository layers +- [ADR-005: Adoption of Swift Observation Framework](ADR-005-adoption-of-swift-observation-framework.md) - ViewModels use @MainActor, not actor + +**Notes:** + +Actor isolation for services and repositories provides the right balance of thread safety and performance. It allows these layers to safely manage concurrent operations like networking, persistence, streaming, and caching while maintaining clean async/await APIs for consumers. Using actors throughout these layers ensures UI work stays on the main thread while data operations execute safely in the background. diff --git a/Documentation/ADR/ADR-012-dto-based-data-flow.md b/Documentation/ADR/ADR-012-dto-based-data-flow.md new file mode 100644 index 0000000..de8f7ed --- /dev/null +++ b/Documentation/ADR/ADR-012-dto-based-data-flow.md @@ -0,0 +1,163 @@ +# ADR-012: DTO-Based Data Flow + +**Status:** Accepted + +**Date:** 2025-01-11 + +**Context:** + +When designing data flow through the application layers, I needed to decide how data should be represented and transformed as it moves from the network to the UI. Key considerations included: + +1. **API coupling:** Keep API response formats separate from domain models +2. **Type safety:** Ensure compile-time safety at layer boundaries +3. **Mapping responsibility:** Clear ownership of data transformation +4. **Flexibility:** Enable API changes without affecting domain logic +5. **Testability:** Easy to create test fixtures at each layer + +**Decision:** + +I adopted a DTO (Data Transfer Object) based approach where data flows through distinct representations at each layer: + +``` +Network API + ↓ +DTOs (OMGAPI package) ← Codable, API-shaped structures + ↓ +Repository ← Maps DTOs to domain models or persistence models + ↓ +Domain Models / Persistence Models ← SwiftData @Model or view-friendly structs + ↓ +ViewModels / Views +``` + +**Layer Responsibilities:** + +1. **OMGAPI Package (DTOs)** + - Contains all request and response models + - Matches API structure exactly (Codable) + - Marked `Sendable` for concurrency safety + - Public, shared across features + +2. **NetworkService Layer** + - Fetches data from API + - Decodes responses into DTOs + - No mapping logic - returns DTOs as-is + +3. **Repository Layer** + - Maps DTOs to domain/persistence models + - Applies business logic during transformation + - Coordinates between network DTOs and local storage + +4. **PersistenceService Layer** + - Stores/retrieves domain or DTO representations + - Uses SwiftData @Model types for persistence + +**Example Flow:** + +```swift +// 1. DTO in OMGAPI package +public struct PURLsResponse: Decodable, Sendable { + public let request: RequestResponse + public let response: Response + + public struct Response: Decodable, Sendable { + public let message: String + public let purls: [PURLResponse] + } +} + +public extension PURLsResponse.Response { + struct PURLResponse: Decodable, Sendable { + public let name: String + public let url: URL + public let counter: Int? + } +} + +// 2. NetworkService returns DTOs +actor PURLsNetworkService: PURLsNetworkServiceProtocol { + func fetchPURLs(for address: String) async throws { + let request = OMGAPIFactory.makeAllPURLsRequest(for: address) + let response: PURLsResponse = try await client.run(request) + // Emit DTOs through stream + continuation.yield(response.response.purls) + } +} + +// 3. Repository maps to persistence model +actor PURLsRepository: PURLsRepositoryProtocol { + private func startPURLsSync() { + for await purls in networkService.purlsStream() { + let storablePurls = purls.map { purlResponse in + StorablePURL( + address: current, + purlResponse: purlResponse // Map DTO to @Model + ) + } + try await persistenceService.storePURLs(purls: storablePurls) + } + } +} + +// 4. SwiftData persistence model +@Model +final class StorablePURL { + var address: String + var name: String + var url: URL + var counter: Int? + + init(address: String, purlResponse: PURLResponse) { + self.address = address + self.name = purlResponse.name + self.url = purlResponse.url + self.counter = purlResponse.counter + } +} +``` + +**Mapping Patterns:** + +- **DTO → @Model:** Repository maps during persistence operations +- **@Model → View Models:** SwiftData queries provide models directly to views +- **DTO → Domain Structs:** Some features use lightweight structs instead of @Model +- **No direct DTO to View:** Views never see DTOs, only domain models + +**Consequences:** + +### Positive + +- **Decoupling:** API changes don't ripple through all layers +- **Type safety:** Each layer has appropriate types for its purpose +- **Clear boundaries:** Transformation happens at well-defined points +- **Testability:** Easy to create fixtures at each layer +- **Sendable safety:** DTOs are Sendable, enabling actor isolation +- **Flexibility:** Can change domain models without affecting API contract + +### Negative + +- **Mapping boilerplate:** Need code to transform between representations +- **Multiple representations:** Same data exists in different forms +- **Memory overhead:** Temporary DTO objects during transformation + +### Neutral + +- **Mapping complexity:** Simple for straightforward cases, more complex for nested structures +- **Performance impact:** Mapping cost is generally negligible for typical data sizes + +**Why Not Direct API Models:** + +Alternatives considered: +- **Use DTOs everywhere:** Couples domain logic to API structure +- **Use domain models for network:** Forces Codable on domain types +- **Manual JSON parsing:** Error-prone and verbose + +**Related Decisions:** + +- [ADR-002: Layered Architecture and Dependency Direction](ADR-002-layered-architecture-and-dependency-direction.md) - DTOs flow through layers +- [ADR-006: Swift Data over Core Data](ADR-006-swift-data-over-core-data.md) - @Model types are mapping targets +- [ADR-007: MicroClient for HTTP Communication](ADR-007-microclient-for-http-communication.md) - NetworkRequest uses DTOs for response types + +**Notes:** + +The DTO-based approach provides clean separation between API contracts and domain models. While it requires mapping code, the benefits of decoupling and type safety outweigh the boilerplate cost. The OMGAPI package serves as the single source of truth for API data structures. diff --git a/Documentation/ADR/ADR-013-repository-caching-and-streaming-patterns.md b/Documentation/ADR/ADR-013-repository-caching-and-streaming-patterns.md new file mode 100644 index 0000000..a0505b4 --- /dev/null +++ b/Documentation/ADR/ADR-013-repository-caching-and-streaming-patterns.md @@ -0,0 +1,159 @@ +# ADR-013: Repository Caching and Streaming Patterns + +**Status:** Accepted + +**Date:** 2025-01-11 + +**Context:** + +Repositories coordinate between NetworkService and PersistenceService layers, managing how data flows between remote APIs and local storage. I needed to establish patterns for: + +1. **Data freshness:** When to fetch from network vs local cache +2. **Real-time updates:** How to keep UI synchronized with data changes +3. **Offline support:** Enable viewing cached data without network +4. **Conflict resolution:** Handle discrepancies between local and remote data +5. **User experience:** Minimize loading states and perceived latency + +**Decision:** + +I adopted a streaming-based caching pattern where repositories use AsyncStream to coordinate continuous data synchronization between network and persistence layers. + +**Key Patterns:** + +### 1. Stream-Based Synchronization + +Repositories set up AsyncStream listeners that automatically persist incoming data: + +```swift +actor PURLsRepository: PURLsRepositoryProtocol { + private var streamTask: Task? + + init(...) { + Task { + await startPURLsSync() + } + } + + private func startPURLsSync() { + streamTask = Task { [weak self] in + guard let self else { return } + + // Listen to network service stream + for await purls in networkService.purlsStream() { + guard !Task.isCancelled else { break } + + // Automatically persist incoming data + let storablePurls = purls.map { purlResponse in + StorablePURL(address: current, purlResponse: purlResponse) + } + try await persistenceService.storePURLs(purls: storablePurls) + } + } + } +} +``` + +### 2. Network Service Streams + +NetworkServices emit data through AsyncStream, enabling reactive updates: + +```swift +actor PURLsNetworkService: PURLsNetworkServiceProtocol { + private let purlsStreamContinuation: AsyncStream<[PURLResponse]>.Continuation + + func fetchPURLs(for address: String) async throws { + let request = OMGAPIFactory.makeAllPURLsRequest(for: address) + let response: PURLsResponse = try await client.run(request) + + // Emit through stream for automatic caching + continuation.yield(response.response.purls) + } + + func purlsStream() -> AsyncStream<[PURLResponse]> { + purlsAsyncStream + } +} +``` + +### 3. SwiftData as Cache + +PersistenceServices use SwiftData's ModelContainer, which views can query directly: + +```swift +public protocol PURLsRepositoryProtocol: Sendable { + var purlsContainer: ModelContainer { get } + // ... +} + +// Views query directly +@Query(sort: \StorablePURL.name) var purls: [StorablePURL] +``` + +**Caching Strategy:** + +- **Write-through:** Network fetches automatically persist to Swift Data via streams +- **Read from cache:** Views use SwiftData @Query to read local data +- **Explicit refresh:** User-triggered or app lifecycle events fetch from network +- **No complex merge:** Network data is source of truth, overwrites local +- **Optimistic updates:** Some operations update UI immediately, then sync + +**Benefits of Stream-Based Approach:** + +1. **Automatic synchronization:** Data flows continuously from network to storage +2. **Decoupled coordination:** Repository doesn't manually call persistence after each network call +3. **Real-time updates:** UI stays synchronized via SwiftData observation +4. **Task management:** Stream tasks are managed in repository lifecycle +5. **Cancellation support:** Streams can be cancelled on repository deinit + +**Conflict Resolution:** + +Current approach: **Server wins** (last-write-wins) +- Network data always overwrites local cache +- No complex merge logic +- Suitable for single-user, single-device scenarios +- Conflicts rare due to API design + +**Cache Invalidation:** + +- **Explicit fetch:** Repository `fetchPURLs()` method triggers network call +- **Lifecycle events:** App foreground, user login trigger refreshes +- **Mutation operations:** Create/delete operations automatically refresh via stream +- **No TTL:** Data remains valid until explicitly refreshed + +**Consequences:** + +### Positive + +- **Reactive UI:** SwiftData observation keeps views updated automatically +- **Offline viewing:** Local cache available without network +- **Clean coordination:** Streaming pattern reduces manual orchestration +- **Type safety:** @Model types provide compile-time safety +- **Performance:** Views read from local SwiftData, not waiting for network + +### Negative + +- **Memory:** Stream tasks run continuously during repository lifetime +- **Complexity:** Async stream coordination requires understanding async patterns +- **Limited offline:** Can't create/modify data offline (server is source of truth) + +### Neutral + +- **Conflict resolution:** Simple strategy works for current use case +- **Cache size:** No automatic cleanup, relies on periodic full refreshes + +**When to Fetch:** + +1. **User-initiated:** Pull-to-refresh, explicit button taps +2. **App lifecycle:** Returning to foreground +3. **After mutations:** Create/update/delete operations +4. **Initial load:** First time viewing a feature + +**Related Decisions:** + +- [ADR-006: Swift Data over Core Data](ADR-006-swift-data-over-core-data.md) - SwiftData as cache layer +- [ADR-011: Actor Isolation for Repository and Service Concurrency](ADR-011-actor-isolation-for-repository-concurrency.md) - Stream task management in actors +- [ADR-012: DTO-Based Data Flow](ADR-012-dto-based-data-flow.md) - DTOs flow through streams to persistence + +**Notes:** + +The streaming-based caching pattern provides automatic synchronization with minimal manual coordination. While it requires understanding async streams, it results in clean, reactive code where data flows naturally from network through repositories to local storage and UI. diff --git a/Documentation/ADR/ADR-014-swiftui-first-ui-development.md b/Documentation/ADR/ADR-014-swiftui-first-ui-development.md new file mode 100644 index 0000000..b8baa50 --- /dev/null +++ b/Documentation/ADR/ADR-014-swiftui-first-ui-development.md @@ -0,0 +1,135 @@ +# ADR-014: SwiftUI-First UI Development + +**Status:** Accepted + +**Date:** 2025-01-11 + +**Context:** + +When building the macOS application UI, I needed to decide on the UI framework approach. The options were: + +1. **AppKit:** Traditional macOS framework with mature APIs +2. **Mixed approach:** SwiftUI with AppKit for specific features +3. **Pure SwiftUI:** SwiftUI exclusively, no AppKit/UIKit + +Key considerations: +- **Modern development:** SwiftUI represents Apple's future direction +- **Cross-platform potential:** SwiftUI code could be reused for iOS/iPadOS +- **Declarative UI:** Simpler state management with SwiftUI +- **Consistency:** Single framework approach throughout +- **Learning investment:** SwiftUI is the better long-term investment + +**Decision:** + +I adopted a pure SwiftUI approach where: + +1. **All UI is SwiftUI** - Views, scenes, navigation, everything +2. **No AppKit or UIKit** - Zero usage of legacy frameworks +3. **DesignSystem package** - Shared SwiftUI components and styling +4. **View composition over inheritance** - SwiftUI patterns throughout +5. **SwiftUI-native solutions** - Work within SwiftUI's capabilities + +**SwiftUI Usage:** + +- **Views:** All feature views built with SwiftUI +- **Navigation:** NavigationSplitView, NavigationStack +- **Scenes:** WindowGroup for auxiliary windows +- **Modifiers:** Custom view modifiers in DesignSystem +- **State management:** @Observable view models (not Combine) +- **Layout:** SwiftUI layout system (VStack, HStack, Grid, etc.) + +**DesignSystem Package:** + +Central package for reusable UI components: + +```swift +// Shared components +- CardViewModifier +- RoundImageModifier +- TextFieldCardModifier +- TextEditorCardModifier +- CircularToolbarModifier + +// Toolbar items +- RefreshToolbarItem +- ProgressToolbarItem +- AddressPickerToolbarItem +- SelectionToolbarItem + +// Colors and styling +- Consistent color palette +- Shared spacing constants +- Common button styles +``` + +**View Architecture:** + +```swift +// Feature view structure +struct StatusApp: View { + @State private var viewModel: StatusAppViewModel + + var body: some View { + VStack { + makeHeaderView() + makeContentView() + makeFooterView() + } + } + + @ViewBuilder + private func makeHeaderView() -> some View { + // Composed subviews + } +} +``` + +**Composition Patterns:** + +- **@ViewBuilder methods:** Break down complex views +- **Extracted views:** Separate files for reusable components +- **Modifiers:** Chain modifiers from DesignSystem +- **No massive view bodies:** Keep views readable and composable + +**Consequences:** + +### Positive + +- **Modern codebase:** Using Apple's recommended UI framework exclusively +- **Declarative syntax:** Easier to understand and maintain than imperative frameworks +- **Live previews:** Fast iteration with SwiftUI Previews +- **State management:** Natural integration with @Observable +- **Code reuse:** DesignSystem components shared across features +- **Future-proof:** Aligned with Apple's platform direction +- **Cross-platform potential:** Could port to iOS/iPadOS with minimal changes +- **No framework mixing:** Single mental model throughout codebase +- **Consistency:** All UI follows SwiftUI patterns + +### Negative + +- **SwiftUI limitations:** Must work within framework capabilities +- **Evolving APIs:** SwiftUI changes across OS versions +- **Learning curve:** Some features require creative SwiftUI solutions + +### Neutral + +- **API changes:** Need to stay current with SwiftUI evolution +- **Testing:** SwiftUI testing approaches still maturing +- **Performance:** Understanding SwiftUI's performance characteristics + +**Design System Benefits:** + +- **Consistency:** Shared components ensure uniform UI +- **Maintainability:** Update once, apply everywhere +- **Productivity:** Reusable components speed development +- **Isolation:** Design system can be developed/tested independently + +**Related Decisions:** + +- [ADR-003: Feature-Based Package Organization](ADR-003-feature-based-package-organization.md) - Each feature contains SwiftUI views +- [ADR-005: Adoption of Swift Observation Framework](ADR-005-adoption-of-swift-observation-framework.md) - @Observable works naturally with SwiftUI +- [ADR-016: SwiftUI Previews with Mother Objects](ADR-016-swiftui-previews-with-mother-objects.md) - Previews enable rapid SwiftUI development + +**Notes:** + +Pure SwiftUI development provides a consistent, modern approach to UI development. By committing fully to SwiftUI without mixing in AppKit or UIKit, the codebase maintains simplicity and aligns with Apple's platform direction. The DesignSystem package ensures consistency across features, and all UI functionality is achieved using SwiftUI-native solutions. diff --git a/Documentation/ADR/ADR-015-context-menu-sharing-patterns.md b/Documentation/ADR/ADR-015-context-menu-sharing-patterns.md new file mode 100644 index 0000000..99cf8c8 --- /dev/null +++ b/Documentation/ADR/ADR-015-context-menu-sharing-patterns.md @@ -0,0 +1,167 @@ +# ADR-015: Context Menu Sharing Patterns + +**Status:** Accepted + +**Date:** 2025-01-11 + +**Context:** + +Content items throughout the application (PURLs, pictures, weblog entries, pastes) need consistent sharing capabilities. Users should be able to: + +1. **Copy URLs** in various formats (plain URL, Markdown) +2. **Share externally** using system share sheet +3. **Share internally** to Statuslog +4. **Manage items** with edit/delete actions + +I needed to establish a consistent pattern for context menus that would: +- Provide intuitive sharing options +- Maintain consistency across features +- Use native macOS patterns +- Be easy to implement in new features + +**Decision:** + +I adopted a standardized context menu structure using SwiftUI's native `ShareLink` for external sharing, combined with custom copy and internal sharing actions. + +**Context Menu Structure:** + +All content item views follow this consistent ordering: + +1. **Edit/Management actions** (e.g., Edit, Modify) +2. **Copy options** (URL, Markdown formats) +3. **Native ShareLink** - System share sheet +4. **Internal sharing** (Share on Statuslog) +5. **Delete actions** (destructive, at bottom) + +**Views with Context Menus:** + +- `PURLView` - PURL management with `viewModel.permanentURL` +- `PictureView` - Picture sharing with `viewModel.somePicsURL` +- `WeblogEntryView` - Blog post sharing with `viewModel.permanentURL` +- `PasteView` - Paste sharing with `viewModel.permanentURL` (public pastes only) + +**ShareLink Implementation:** + +```swift +@ViewBuilder +private func makeShareMenuItem() -> some View { + ShareLink(item: viewModel.permanentURL) { + Label("Share", systemImage: "square.and.arrow.up") + } +} +``` + +**Complete Context Menu Example:** + +```swift +struct PURLView: View { + @State private var viewModel: PURLViewModel + + var body: some View { + // ... view content ... + .contextMenu { + // 1. Edit/Management actions + Button("Edit") { + viewModel.edit() + } + + Divider() + + // 2. Copy options + Button("Copy URL") { + viewModel.copyURL() + } + + Button("Copy as Markdown") { + viewModel.copyAsMarkdown() + } + + Divider() + + // 3. Native ShareLink + ShareLink(item: viewModel.permanentURL) { + Label("Share", systemImage: "square.and.arrow.up") + } + + Divider() + + // 4. Internal sharing + Button("Share on Statuslog") { + viewModel.shareOnStatuslog() + } + + Divider() + + // 5. Delete actions (destructive) + Button("Delete", role: .destructive) { + viewModel.delete() + } + } + } +} +``` + +**Copy Format Conventions:** + +- **Copy URL:** Plain URL string for pasting anywhere +- **Copy as Markdown:** `[Title](URL)` format for documentation +- **Copy as Rich Text:** Formatted text with embedded link (when applicable) + +**ShareLink Benefits:** + +1. **Native integration:** Uses macOS share sheet +2. **System services:** Access to Mail, Messages, AirDrop, etc. +3. **User familiarity:** Standard macOS sharing experience +4. **No custom code:** SwiftUI provides implementation +5. **Consistent icon:** `square.and.arrow.up` system symbol + +**Internal Sharing Pattern:** + +"Share on Statuslog" creates a status update with the item's URL: +- Provides cross-feature integration +- Encourages content discovery +- Maintains context within the application + +**Consequences:** + +### Positive + +- **Consistency:** All content items share similar interaction patterns +- **Discoverability:** Context menus make sharing obvious +- **Native feel:** ShareLink provides standard macOS experience +- **Flexibility:** Multiple copy formats serve different use cases +- **Maintenance:** Pattern is easy to replicate in new features + +### Negative + +- **Repetition:** Similar code across multiple views (mitigated by view model methods) +- **Menu length:** Full menu can be lengthy with all options + +### Neutral + +- **Menu order:** Established order must be maintained for consistency +- **Feature parity:** New content types should follow same pattern + +**Implementation Guidelines:** + +1. **Always include ShareLink** - Provide native system sharing +2. **Maintain consistent ordering** - Follow the five-section structure +3. **Use dividers** - Separate logical groups of actions +4. **Destructive actions last** - Delete always at bottom with `.destructive` role +5. **View model methods** - Keep logic in view model, not view + +**Accessibility:** + +- Label text clearly describes action +- System icons provide visual recognition +- Keyboard shortcuts for common actions +- VoiceOver-friendly labels + +**Related Decisions:** + +- [ADR-014: SwiftUI-First UI Development](ADR-014-swiftui-first-ui-development.md) - ShareLink is SwiftUI-native +- [ADR-003: Feature-Based Package Organization](ADR-003-feature-based-package-organization.md) - Pattern implemented across features + +**Notes:** + +The standardized context menu pattern with ShareLink provides a consistent, native sharing experience across all content types. By establishing clear conventions for menu structure and actions, new features can easily maintain consistency with existing patterns. The use of SwiftUI's ShareLink ensures the app feels native to macOS while providing powerful sharing capabilities. diff --git a/Documentation/ADR/ADR-016-swiftui-previews-with-mother-objects.md b/Documentation/ADR/ADR-016-swiftui-previews-with-mother-objects.md new file mode 100644 index 0000000..7e6a5bd --- /dev/null +++ b/Documentation/ADR/ADR-016-swiftui-previews-with-mother-objects.md @@ -0,0 +1,210 @@ +# ADR-016: SwiftUI Previews with Mother Objects + +**Status:** Accepted + +**Date:** 2025-01-11 + +**Context:** + +SwiftUI Previews enable rapid UI development by providing live visual feedback without running the full application. However, previews require: + +1. **Realistic data:** Views need properly configured view models and data +2. **Dependencies:** View models depend on services, repositories, etc. +3. **Multiple states:** Need to preview different UI states (empty, loaded, error) +4. **Maintainability:** Fixture creation should be reusable and consistent +5. **Simplicity:** Previews should be easy to write + +I needed a pattern for creating test fixtures that would: +- Provide realistic preview data +- Be reusable across previews and tests +- Keep preview code clean and readable +- Make it easy to create different scenarios + +**Decision:** + +I adopted the Mother Object pattern (https://martinfowler.com/bliki/ObjectMother.html) for creating test fixtures used in SwiftUI Previews. Mother Objects are factory classes that create fully-configured test objects with realistic data. + +**Implementation Pattern:** + +### Mother Object Structure + +```swift +#if DEBUG + +enum SidebarViewModelMother { + @MainActor + static func makeSidebarViewModel( + loggedIn: Bool = false + ) -> SidebarViewModel { + .init( + authSessionService: AuthSessionServiceMother.makeAuthSessionService( + loggedIn: loggedIn + ) + ) + } +} + +#endif +``` + +**Key Characteristics:** + +1. **DEBUG-only:** Wrapped in `#if DEBUG` to exclude from release builds +2. **Enum (no cases):** Namespace for factory methods, cannot be instantiated +3. **Static methods:** All factory methods are static +4. **Sensible defaults:** Parameters have default values for common cases +5. **Composable:** Mother Objects can call other Mother Objects +6. **@MainActor when needed:** For types requiring main thread + +**Usage in Previews:** + +```swift +#Preview("Logged in") { + SidebarView( + viewModel: SidebarViewModelMother.makeSidebarViewModel( + loggedIn: true + ), + selection: .constant(.statuslog) + ) + .frame(width: 180) +} + +#Preview("Logged out") { + SidebarView( + viewModel: SidebarViewModelMother.makeSidebarViewModel( + loggedIn: false + ), + selection: .constant(.weblog) + ) + .frame(width: 180) +} +``` + +**Mother Object Patterns:** + +### Service Mocks + +```swift +enum AuthSessionServiceMother { + static func makeAuthSessionService( + loggedIn: Bool = true + ) -> any AuthSessionServiceProtocol { + MockAuthSessionService(isLoggedIn: loggedIn) + } +} +``` + +### View Models + +```swift +enum StatusViewModelMother { + @MainActor + static func makeStatusViewModel( + statuses: [Status] = StatusMother.makeStatuses(), + isLoading: Bool = false + ) -> StatusViewModel { + .init( + statuses: statuses, + isLoading: isLoading, + repository: StatusRepositoryMother.makeRepository() + ) + } +} +``` + +### Domain Models + +```swift +enum StatusMother { + static func makeStatus( + content: String = "Test status", + createdAt: Date = Date() + ) -> Status { + Status( + id: UUID().uuidString, + content: content, + createdAt: createdAt + ) + } + + static func makeStatuses(count: Int = 5) -> [Status] { + (0.. + + + + diff --git a/OMG.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/OMG.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..db776a1 --- /dev/null +++ b/OMG.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,42 @@ +{ + "originHash" : "cf8a43d25f0e66b6fe0462bffcf0426bf3b10f5b9ce5753e355e7513f9738e69", + "pins" : [ + { + "identity" : "microclient", + "kind" : "remoteSourceControl", + "location" : "https://github.com/otaviocc/MicroClient.git", + "state" : { + "revision" : "794c206d1bf811f085f3db08901dead0554437f7", + "version" : "0.0.27" + } + }, + { + "identity" : "microcontainer", + "kind" : "remoteSourceControl", + "location" : "https://github.com/otaviocc/MicroContainer.git", + "state" : { + "revision" : "255f98b9807a44fad475df01d14a2f47c501a4b3", + "version" : "0.0.6" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms", + "state" : { + "revision" : "2773d4125311133a2f705ec374c363a935069d45", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" + } + } + ], + "version" : 3 +} diff --git a/OMG.xcodeproj/xcshareddata/xcschemes/OMG.xcscheme b/OMG.xcodeproj/xcshareddata/xcschemes/OMG.xcscheme new file mode 100644 index 0000000..7a66c27 --- /dev/null +++ b/OMG.xcodeproj/xcshareddata/xcschemes/OMG.xcscheme @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/OMG/Assets/Assets.xcassets/AccentColor.colorset/Contents.json b/OMG/Assets/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..b1f3594 --- /dev/null +++ b/OMG/Assets/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.678", + "green" : "0.410", + "red" : "0.999" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/OMG/Assets/Assets.xcassets/Contents.json b/OMG/Assets/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/OMG/Assets/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/OMG/Assets/TritonIcon.icon/Assets/1024.png b/OMG/Assets/TritonIcon.icon/Assets/1024.png new file mode 100644 index 0000000..cec151c Binary files /dev/null and b/OMG/Assets/TritonIcon.icon/Assets/1024.png differ diff --git a/OMG/Assets/TritonIcon.icon/icon.json b/OMG/Assets/TritonIcon.icon/icon.json new file mode 100644 index 0000000..8f075e2 --- /dev/null +++ b/OMG/Assets/TritonIcon.icon/icon.json @@ -0,0 +1,42 @@ +{ + "fill-specializations" : [ + { + "value" : "system-light" + }, + { + "appearance" : "dark", + "value" : "system-dark" + } + ], + "groups" : [ + { + "layers" : [ + { + "fill" : "automatic", + "glass" : true, + "hidden" : false, + "image-name" : "1024.png", + "name" : "1024", + "position" : { + "scale" : 0.8, + "translation-in-points" : [ + 0, + 0 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "squares" : "shared" + } +} \ No newline at end of file diff --git a/OMG/Preview Content/Preview Assets.xcassets/Contents.json b/OMG/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/OMG/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/OMG/Supporting Files/Info.plist b/OMG/Supporting Files/Info.plist new file mode 100644 index 0000000..391e152 --- /dev/null +++ b/OMG/Supporting Files/Info.plist @@ -0,0 +1,19 @@ + + + + + CFBundleURLTypes + + + CFBundleTypeRole + Viewer + CFBundleURLName + com.otaviocc.triton + CFBundleURLSchemes + + com.otaviocc.triton-triton + + + + + diff --git a/OMG/Supporting Files/OMG.entitlements b/OMG/Supporting Files/OMG.entitlements new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/OMG/Supporting Files/OMG.entitlements @@ -0,0 +1,5 @@ + + + + + diff --git a/OMG/TritonApp.swift b/OMG/TritonApp.swift new file mode 100644 index 0000000..c5e272c --- /dev/null +++ b/OMG/TritonApp.swift @@ -0,0 +1,67 @@ +import SwiftUI + +/// The main application entry point for OMG. +/// +/// `TritonApp` configures and manages all scenes in the application, including: +/// - Main window with primary navigation +/// - Feature-specific composition scenes (Status, PURLs, Web Page, Now Page, Weblog, Pics, Pastebin) +/// - Application settings +/// +/// All scenes share a single `TritonEnvironment` instance for dependency injection. +@main +struct TritonApp: App { + + // MARK: - Properties + + private let environment = TritonEnvironment() + + // MARK: - Public + + var body: some Scene { + // Main scene + + TritonScene( + environment: environment + ) + + // Feature scenes + + environment + .statusAppFactory + .makeScene() + + environment + .purlsAppFactory + .makeScene() + + environment + .webpageAppFactory + .makeScene() + + environment + .nowAppFactory + .makeScene() + + environment + .weblogAppFactory + .makeScene() + + environment + .picsAppFactory + .makeScene() + + environment + .pastebinAppFactory + .makeScene() + + // Settings + + #if os(macOS) + Settings { + SettingsView( + environment: environment + ) + } + #endif + } +} diff --git a/OMG/TritonEnvironment.swift b/OMG/TritonEnvironment.swift new file mode 100644 index 0000000..4fed848 --- /dev/null +++ b/OMG/TritonEnvironment.swift @@ -0,0 +1,378 @@ +import Account +import AccountUpdateService +import Auth +import AuthSessionService +import AuthSessionServiceInterface +import ClipboardService +import MicroClient +import MicroContainer +import Now +import OMGAPI +import Pastebin +import Pics +import PURLs +import SessionService +import SessionServiceInterface +import Shortcuts +import Sidebar +import Status +import Weblog +import Webpage + +/// The main dependency injection container protocol for the OMG application. +/// +/// `TritonEnvironmentProtocol` defines the contract for resolving and providing access +/// to all core application dependencies. It serves as the central hub for dependency +/// injection, ensuring that shared services and feature-specific app factories are +/// available throughout the application lifecycle. +/// +/// ## Architecture +/// +/// The protocol follows a modular architecture pattern where: +/// - **Core Services**: Shared infrastructure services used across multiple features +/// - **App Factories**: Feature-specific factories that create complete app modules +/// - **Dependency Resolution**: All dependencies are resolved through a container-based system +/// +/// ## Core Services +/// +/// The protocol provides access to fundamental services that are shared across features: +/// - Authentication and session management +/// - Network communication +/// - User session state +/// +/// ## Feature Factories +/// +/// Each major application feature has its own dedicated factory that encapsulates +/// all the dependencies needed for that feature area: +/// - Status updates and timeline management +/// - User authentication flows +/// - Account management and profile settings +/// - PURLs (permanent URL) creation and management +/// - "Now page" content editing +/// - Web page creation and editing +/// - Pastebin functionality +/// - Weblog posting and management +/// - Application sidebar navigation +/// +/// ## Usage +/// +/// Conforming types should handle dependency registration and resolution internally, +/// typically using a dependency injection container: +/// +/// ```swift +/// struct MyEnvironment: TritonEnvironmentProtocol { +/// private let container = DependencyContainer() +/// +/// var authSessionService: any AuthSessionServiceProtocol { +/// container.resolve() +/// } +/// +/// // ... other dependencies +/// } +/// ``` +/// +/// ## Thread Safety +/// +/// All properties should be thread-safe and support concurrent access since they +/// may be accessed from multiple contexts throughout the application. +protocol TritonEnvironmentProtocol { + + // MARK: - Core Services + + /// Provides authentication session management and token handling. + /// + /// Used for managing user authentication state, storing and retrieving + /// access tokens, and handling authentication lifecycle events. + var authSessionService: any AuthSessionServiceProtocol { get } + + /// Provides user session state management across the application. + /// + /// Manages the current user's session information, selected addresses, + /// and session-related state that needs to be shared between features. + var sessionService: any SessionServiceProtocol { get } + + /// Provides network communication capabilities for API requests. + /// + /// Configured with authentication token providers and handles all + /// HTTP communication with the OMG API services. + var networkClient: any NetworkClientProtocol { get } + + /// Provides App Intent integration for Spotlight, Siri, and Shortcuts. + /// + /// Observes notifications posted by App Intents and coordinates window + /// opening actions, bridging system-level shortcuts to the application UI. + var shortcutsService: any ShortcutsServiceProtocol { get } + + /// Provides clipboard capabilities. + /// + /// Used for adding URLs, Markdown content, and more to the system's + /// pasteboard. + var clipboardService: any ClipboardServiceProtocol { get } + + // MARK: - Feature App Factories + + /// Factory for creating Status feature components. + /// + /// Provides access to status posting, timeline viewing, and status + /// management functionality. + var statusAppFactory: StatusAppFactory { get } + + /// Factory for creating Authentication feature components. + /// + /// Handles user login, logout, and authentication flow management. + var authAppFactory: AuthAppFactory { get } + + /// Factory for creating Account management components. + /// + /// Provides user account viewing, profile management, and account + /// settings functionality. + var accountAppFactory: AccountAppFactory { get } + + /// Factory for creating Account update service components. + /// + /// Handles account information updates, address changes, and profile + /// synchronization across the application. + var accountUpdateAppFactory: AccountUpdateAppFactory { get } + + /// Factory for creating Sidebar navigation components. + /// + /// Provides the main application navigation sidebar with feature + /// selection and routing capabilities. + var sidebarAppFactory: SidebarAppFactory { get } + + /// Factory for creating PURLs (Permanent URLs) feature components. + /// + /// Handles creation, management, and sharing of permanent redirect URLs. + var purlsAppFactory: PURLsAppFactory { get } + + /// Factory for creating "Now page" feature components. + /// + /// Provides editing and management of the user's "now" status page content. + var nowAppFactory: NowAppFactory { get } + + /// Factory for creating Web page editing components. + /// + /// Handles creation and editing of web pages hosted on the OMG platform. + var webpageAppFactory: WebpageAppFactory { get } + + /// Factory for creating Pastebin feature components. + /// + /// Provides text paste creation, editing, sharing, and management functionality. + var pastebinAppFactory: PastebinAppFactory { get } + + /// Factory for creating Weblog feature components. + /// + /// Handles blog post creation, editing, and weblog management functionality. + var weblogAppFactory: WeblogAppFactory { get } + + /// Factory for creating Pics feature components. + /// + /// Provides image hosting, sharing, and management functionality + /// on the some.pics subdomain platform. + var picsAppFactory: PicsAppFactory { get } +} + +struct TritonEnvironment: TritonEnvironmentProtocol { + + // MARK: - Properties + + var authSessionService: any AuthSessionServiceProtocol { container.resolve() } + var sessionService: any SessionServiceProtocol { container.resolve() } + var networkClient: any NetworkClientProtocol { container.resolve() } + var shortcutsService: any ShortcutsServiceProtocol { container.resolve() } + var clipboardService: any ClipboardServiceProtocol { container.resolve() } + var statusAppFactory: StatusAppFactory { container.resolve() } + var authAppFactory: AuthAppFactory { container.resolve() } + var accountAppFactory: AccountAppFactory { container.resolve() } + var accountUpdateAppFactory: AccountUpdateAppFactory { container.resolve() } + var sidebarAppFactory: SidebarAppFactory { container.resolve() } + var purlsAppFactory: PURLsAppFactory { container.resolve() } + var nowAppFactory: NowAppFactory { container.resolve() } + var webpageAppFactory: WebpageAppFactory { container.resolve() } + var pastebinAppFactory: PastebinAppFactory { container.resolve() } + var weblogAppFactory: WeblogAppFactory { container.resolve() } + var picsAppFactory: PicsAppFactory { container.resolve() } + + private let container = DependencyContainer() + + // MARK: - Lifecycle + + init() { + self.init( + authSessionServiceFactory: AuthSessionServiceFactory(), + sessionServiceFactory: SessionServiceFactory(), + networkClient: OMGAPIFactory(), + shortcutsServiceFactory: ShortcutsServiceFactory(), + clipboardServiceFactory: ClipboardServiceFactory() + ) + } + + init( + authSessionServiceFactory: any AuthSessionServiceFactoryProtocol, + sessionServiceFactory: any SessionServiceFactoryProtocol, + networkClient: any OMGAPIFactoryProtocol, + shortcutsServiceFactory: any ShortcutsServiceFactoryProtocol, + clipboardServiceFactory: any ClipboardServiceFactoryProtocol + ) { + container.register( + type: (any AuthSessionServiceProtocol).self, + allocation: .static + ) { _ in + authSessionServiceFactory.makeAuthSessionService() + } + + container.register( + type: (any SessionServiceProtocol).self, + allocation: .static + ) { _ in + sessionServiceFactory.makeSessionService() + } + + container.register( + type: (any NetworkClientProtocol).self, + allocation: .static + ) { container in + let authSessionService = container.resolve() as any AuthSessionServiceProtocol + return networkClient.makeOMGAPIClient( + authTokenProvider: { + await authSessionService.accessToken + } + ) + } + + container.register( + type: (any ShortcutsServiceProtocol).self, + allocation: .static + ) { _ in + shortcutsServiceFactory.makeShortcutsService() + } + + container.register( + type: (any ClipboardServiceProtocol).self, + allocation: .static + ) { _ in + clipboardServiceFactory.makeClipboardService() + } + + container.register( + type: StatusAppFactory.self, + allocation: .static + ) { container in + StatusAppFactory( + sessionService: container.resolve(), + authSessionService: container.resolve(), + networkClient: container.resolve(), + clipboardService: container.resolve() + ) + } + + container.register( + type: AuthAppFactory.self, + allocation: .static + ) { container in + AuthAppFactory( + authSessionService: container.resolve(), + networkClient: container.resolve() + ) + } + + container.register( + type: AccountAppFactory.self, + allocation: .static + ) { container in + AccountAppFactory( + sessionService: container.resolve() + ) + } + + container.register( + type: AccountUpdateAppFactory.self, + allocation: .static + ) { container in + AccountUpdateAppFactory( + sessionService: container.resolve(), + authSessionService: container.resolve(), + networkClient: container.resolve() + ) + } + + container.register( + type: SidebarAppFactory.self, + allocation: .static + ) { container in + SidebarAppFactory( + authSessionService: container.resolve() + ) + } + + container.register( + type: PURLsAppFactory.self, + allocation: .static + ) { container in + PURLsAppFactory( + networkClient: container.resolve(), + authSessionService: container.resolve(), + sessionService: container.resolve(), + clipboardService: container.resolve() + ) + } + + container.register( + type: NowAppFactory.self, + allocation: .static + ) { container in + NowAppFactory( + networkClient: container.resolve(), + authSessionService: container.resolve(), + sessionService: container.resolve() + ) + } + + container.register( + type: WebpageAppFactory.self, + allocation: .static + ) { container in + WebpageAppFactory( + networkClient: container.resolve(), + authSessionService: container.resolve(), + sessionService: container.resolve() + ) + } + + container.register( + type: PastebinAppFactory.self, + allocation: .static + ) { container in + PastebinAppFactory( + networkClient: container.resolve(), + authSessionService: container.resolve(), + sessionService: container.resolve(), + clipboardService: container.resolve() + ) + } + + container.register( + type: WeblogAppFactory.self, + allocation: .static + ) { container in + WeblogAppFactory( + networkClient: container.resolve(), + authSessionService: container.resolve(), + sessionService: container.resolve(), + clipboardService: container.resolve() + ) + } + + container.register( + type: PicsAppFactory.self, + allocation: .static + ) { container in + PicsAppFactory( + networkClient: container.resolve(), + authSessionService: container.resolve(), + sessionService: container.resolve(), + clipboardService: container.resolve() + ) + } + } +} diff --git a/OMG/TritonScene.swift b/OMG/TritonScene.swift new file mode 100644 index 0000000..ac9d6f7 --- /dev/null +++ b/OMG/TritonScene.swift @@ -0,0 +1,86 @@ +import AccountUpdateService +import Route +import Shortcuts +import SwiftUI + +/// The primary scene that provides the main application window. +/// +/// This scene creates the main application window with a navigation split view containing +/// a sidebar for feature selection and a detail view for the selected feature content. +/// It manages the application's primary navigation and integrates account update services. +struct TritonScene: Scene { + + // MARK: - Properties + + @State private var selection: RouteFeature? = .statuslog + @Environment(\.openWindow) private var openWindow + + private let environment: any TritonEnvironmentProtocol + + // MARK: - Lifecycle + + init( + environment: any TritonEnvironmentProtocol + ) { + self.environment = environment + } + + // MARK: - Public + + var body: some Scene { + WindowGroup( + MainWindow.name, + id: MainWindow.id + ) { + NavigationSplitView( + sidebar: { makeSidebarView() }, + detail: { makeDetailView() } + ) + #if os(macOS) + .navigationTitle("Triton") + #endif + .environment(makeAccountUpdateService()) + .handlesExternalEvents( + preferring: Set(arrayLiteral: "viewer"), + allowing: Set(arrayLiteral: "*") + ) + .onAppear { + environment + .shortcutsService + .setUpObservers(openWindow: openWindow) + } + } + .commandsRemoved() + } + + // MARK: - Private + + @ViewBuilder + private func makeSidebarView() -> some View { + environment.sidebarAppFactory + .makeAppView(selection: $selection) + #if os(macOS) + .navigationSplitViewColumnWidth( + min: 150, + ideal: 150, + max: 200 + ) + #endif + } + + @ViewBuilder + private func makeDetailView() -> some View { + DetailView( + environment: environment, + selectedFeature: $selection + ) + #if os(macOS) + .frame(minWidth: 320, idealWidth: 480) + #endif + } + + private func makeAccountUpdateService() -> AccountUpdateService { + environment.accountUpdateAppFactory + .makeAccountUpdateService() + } +} diff --git a/OMG/Views/DetailView.swift b/OMG/Views/DetailView.swift new file mode 100644 index 0000000..94f4520 --- /dev/null +++ b/OMG/Views/DetailView.swift @@ -0,0 +1,122 @@ +import DesignSystem +import Route +import SwiftUI + +/// A SwiftUI view that displays the detail content for the selected application feature. +/// +/// This view acts as the main content router in the application, switching between +/// different feature views based on the currently selected navigation item. It coordinates +/// with the app's dependency injection container to instantiate the appropriate feature +/// view through their respective app factories. +/// +/// The view handles routing for all major application features including Statuslog, +/// PURLs, Account, Auth, Now page, Webpage, Pastebin, Weblog, and some.pics. +struct DetailView: View { + + // MARK: - Properties + + private let environment: any TritonEnvironmentProtocol + private var selectedFeature: Binding + + // MARK: - Lifecycle + + init( + environment: any TritonEnvironmentProtocol, + selectedFeature: Binding + ) { + self.environment = environment + self.selectedFeature = selectedFeature + } + + // MARK: - Public + + var body: some View { + switch selectedFeature.wrappedValue { + case .statuslog: + makeStatusView() + case .purls: + makePURLsView() + case .account: + makeCurrentAccountView() + case .auth: + makeAuthView() + case .nowPage: + makeNowView() + case .webpage: + makeWebpageView() + case .pastebin: + makePastebinView() + case .weblog: + makeWeblogAppView() + case .somePics: + makePicsAppView() + default: + ContentUnavailableViewFactory.makeNotImplementedView() + } + } + + // MARK: - Private + + @ViewBuilder + private func makeCurrentAccountView() -> some View { + environment + .accountAppFactory + .makeAppView() + } + + @ViewBuilder + private func makeStatusView() -> some View { + environment + .statusAppFactory + .makeAppView() + } + + @ViewBuilder + private func makeAuthView() -> some View { + environment + .authAppFactory + .makeAppView() + } + + @ViewBuilder + private func makeNowView() -> some View { + environment + .nowAppFactory + .makeAppView() + } + + @ViewBuilder + private func makePURLsView() -> some View { + environment + .purlsAppFactory + .makeAppView() + } + + @ViewBuilder + private func makeWebpageView() -> some View { + environment + .webpageAppFactory + .makeAppView() + } + + @ViewBuilder + private func makePastebinView() -> some View { + environment + .pastebinAppFactory + .makeAppView() + } + + @ViewBuilder + private func makeWeblogAppView() -> some View { + environment + .weblogAppFactory + .makeAppView() + } + + @ViewBuilder + private func makePicsAppView() -> some View { + environment + .picsAppFactory + .makeAppView() + } +} diff --git a/OMG/Views/SettingsView.swift b/OMG/Views/SettingsView.swift new file mode 100644 index 0000000..9d6eb2d --- /dev/null +++ b/OMG/Views/SettingsView.swift @@ -0,0 +1,61 @@ +import DesignSystem +import Status +import SwiftUI + +/// A SwiftUI view that displays the application settings interface. +/// +/// This view provides a tabbed interface for managing various application settings +/// across different feature modules. It currently includes settings for the Statuslog +/// feature, with the capability to expand to additional feature settings tabs in the future. +/// +/// The view uses the dependency injection container (`TritonEnvironment`) to access +/// feature-specific settings views through their respective app factories, maintaining +/// loose coupling between the main application and feature modules. +struct SettingsView: View { + + // MARK: - Properties + + private let environment: any TritonEnvironmentProtocol + + // MARK: - Lifecycle + + init( + environment: any TritonEnvironmentProtocol + ) { + self.environment = environment + } + + // MARK: - Public + + var body: some View { + TabView { + makeStatusSettingsView() + .tabItem { + Label("Statuslog", systemImage: "message") + } + + TipJarView() + .tabItem { + Label("Tip Jar", systemImage: "cup.and.saucer.fill") + } + } + .frame(width: 480) + } + + // MARK: - Private + + @ViewBuilder + private func makeStatusSettingsView() -> some View { + environment + .statusAppFactory + .makeSettingsView() + } +} + +// MARK: - Preview + +#Preview { + SettingsView( + environment: TritonEnvironment() + ) +} diff --git a/Packages/Account/.gitignore b/Packages/Account/.gitignore new file mode 100644 index 0000000..3b29812 --- /dev/null +++ b/Packages/Account/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Packages/Account/Package.swift b/Packages/Account/Package.swift new file mode 100644 index 0000000..775ab22 --- /dev/null +++ b/Packages/Account/Package.swift @@ -0,0 +1,51 @@ +// swift-tools-version: 6.2 + +import PackageDescription + +let package = Package( + name: "Account", + platforms: [ + .macOS(.v15), + .iOS(.v18) + ], + products: [ + .library( + name: "Account", + targets: ["Account"] + ) + ], + dependencies: [ + .package( + name: "DesignSystem", + path: "../DesignSystem" + ), + .package( + name: "SessionService", + path: "../SessionService" + ), + .package( + name: "AuthSession", + path: "../AuthSession" + ), + .package( + url: "https://github.com/otaviocc/MicroContainer.git", + from: "0.0.6" + ), + .package( + url: "https://github.com/otaviocc/MicroClient.git", + from: "0.0.27" + ) + ], + targets: [ + .target( + name: "Account", + dependencies: [ + .product(name: "SessionServiceInterface", package: "SessionService"), + .product(name: "AuthSessionServiceInterface", package: "AuthSession"), + "DesignSystem", + "MicroContainer", + "MicroClient" + ] + ) + ] +) diff --git a/Packages/Account/Sources/Account/Environment/AccountEnvironment.swift b/Packages/Account/Sources/Account/Environment/AccountEnvironment.swift new file mode 100644 index 0000000..aefae12 --- /dev/null +++ b/Packages/Account/Sources/Account/Environment/AccountEnvironment.swift @@ -0,0 +1,35 @@ +import MicroContainer +import SessionServiceInterface + +struct AccountEnvironment { + + // MARK: - Properties + + var viewModelFactory: ViewModelFactory { + container.resolve() + } + + private let container = DependencyContainer() + + // MARK: - Lifecycle + + init( + sessionService: any SessionServiceProtocol + ) { + container.register( + type: (any SessionServiceProtocol).self, + allocation: .dynamic + ) { _ in + sessionService + } + + container.register( + type: ViewModelFactory.self, + allocation: .static + ) { container in + ViewModelFactory( + container: container + ) + } + } +} diff --git a/Packages/Account/Sources/Account/Factories/AccountAppFactory.swift b/Packages/Account/Sources/Account/Factories/AccountAppFactory.swift new file mode 100644 index 0000000..4a8d60a --- /dev/null +++ b/Packages/Account/Sources/Account/Factories/AccountAppFactory.swift @@ -0,0 +1,57 @@ +import AuthSessionServiceInterface +import MicroClient +import SessionServiceInterface +import SwiftUI + +/// Factory responsible for creating the account management feature and its views. +/// +/// `AccountAppFactory` manages the account information display including user details, +/// addresses, and account settings. It initializes the account environment with required +/// dependencies and provides methods to create fully configured account views. +/// +/// ## Usage +/// +/// ```swift +/// let factory = AccountAppFactory( +/// sessionService: sessionService +/// ) +/// +/// let accountView = factory.makeAppView() +/// ``` +public final class AccountAppFactory { + + // MARK: - Properties + + private let environment: AccountEnvironment + + // MARK: - Lifecycle + + public init( + sessionService: any SessionServiceProtocol + ) { + environment = .init( + sessionService: sessionService + ) + } + + // MARK: - Public + + /// Creates the main account management view. + /// + /// This method constructs the account feature's root view with all necessary + /// dependencies injected. The view displays user account information including + /// email, addresses, registration date, and address management capabilities. + /// + /// - Returns: A configured account view ready for presentation. + @MainActor + @ViewBuilder + public func makeAppView() -> some View { + let viewModel = environment.viewModelFactory + .makeAccountViewModel() + + AccountView( + viewModel: viewModel + ) + .environment(\.viewModelFactory, environment.viewModelFactory) + } +} diff --git a/Packages/Account/Sources/Account/Factories/ViewModelFactory.swift b/Packages/Account/Sources/Account/Factories/ViewModelFactory.swift new file mode 100644 index 0000000..d76375c --- /dev/null +++ b/Packages/Account/Sources/Account/Factories/ViewModelFactory.swift @@ -0,0 +1,51 @@ +import MicroContainer +import SessionServiceInterface +import SwiftUI + +final class ViewModelFactory: Sendable { + + // MARK: - Properties + + private let container: DependencyContainer + + // MARK: - Lifecycle + + init( + container: DependencyContainer + ) { + self.container = container + } + + // MARK: - Public + + @MainActor + func makeAccountViewModel() -> AccountViewModel { + .init( + sessionService: container.resolve() + ) + } + + @MainActor + func makeAccountDetailsViewModel( + currentAccount: CurrentAccount + ) -> AccountDetailsViewModel { + .init( + currentAccount: currentAccount, + sessionService: container.resolve() + ) + } +} + +// MARK: - Environment + +extension EnvironmentValues { + + @Entry var viewModelFactory: ViewModelFactory = .placeholder +} + +extension ViewModelFactory { + + static let placeholder = ViewModelFactory( + container: .init() + ) +} diff --git a/Packages/Account/Sources/Account/Fixtures/AccountDetailsViewModelMother.swift b/Packages/Account/Sources/Account/Fixtures/AccountDetailsViewModelMother.swift new file mode 100644 index 0000000..5844965 --- /dev/null +++ b/Packages/Account/Sources/Account/Fixtures/AccountDetailsViewModelMother.swift @@ -0,0 +1,21 @@ +#if DEBUG + + import Foundation + import SessionServiceInterface + + enum AccountDetailsViewModelMother { + + @MainActor + static func makeAccountDetailsViewModel() -> AccountDetailsViewModel { + .init( + currentAccount: CurrentAccountMother.makeCurrent(), + sessionService: SessionServiceMother.makeSessionService( + address: .address( + current: "otaviocc" + ) + ) + ) + } + } + +#endif diff --git a/Packages/Account/Sources/Account/Fixtures/AccountEnvironmentMother.swift b/Packages/Account/Sources/Account/Fixtures/AccountEnvironmentMother.swift new file mode 100644 index 0000000..7fea472 --- /dev/null +++ b/Packages/Account/Sources/Account/Fixtures/AccountEnvironmentMother.swift @@ -0,0 +1,16 @@ +#if DEBUG + + import SessionServiceInterface + + enum AccountEnvironmentMother { + + // MARK: - Public + + static func makeAccountEnvironment() -> AccountEnvironment { + .init( + sessionService: SessionServiceMother.makeSessionService() + ) + } + } + +#endif diff --git a/Packages/Account/Sources/Account/Fixtures/AccountMother.swift b/Packages/Account/Sources/Account/Fixtures/AccountMother.swift new file mode 100644 index 0000000..d19ce9a --- /dev/null +++ b/Packages/Account/Sources/Account/Fixtures/AccountMother.swift @@ -0,0 +1,31 @@ +#if DEBUG + + import Foundation + import SessionServiceInterface + + enum CurrentAccountMother { + + // MARK: - Public + + static func makeCurrent() -> CurrentAccount { + .init( + name: "Otavio", + email: "foo@bar.abc", + creation: .distantPast, + addresses: [ + .init( + address: "otaviocc", + creation: .distantPast, + expire: .distantFuture + ), + .init( + address: "otavio", + creation: Date(), + expire: .distantFuture + ) + ] + ) + } + } + +#endif diff --git a/Packages/Account/Sources/Account/Fixtures/AccountViewModelMother.swift b/Packages/Account/Sources/Account/Fixtures/AccountViewModelMother.swift new file mode 100644 index 0000000..b529c21 --- /dev/null +++ b/Packages/Account/Sources/Account/Fixtures/AccountViewModelMother.swift @@ -0,0 +1,23 @@ +#if DEBUG + + import Foundation + import SessionServiceInterface + + enum AccountViewModelMother { + + // MARK: - Public + + @MainActor + static func makeAccountViewModel( + account: Account + ) -> AccountViewModel { + .init( + sessionService: SessionServiceMother + .makeSessionService( + account: account + ) + ) + } + } + +#endif diff --git a/Packages/Account/Sources/Account/Views/Account/AccountView.swift b/Packages/Account/Sources/Account/Views/Account/AccountView.swift new file mode 100644 index 0000000..6d2d1c6 --- /dev/null +++ b/Packages/Account/Sources/Account/Views/Account/AccountView.swift @@ -0,0 +1,84 @@ +import DesignSystem +import SessionServiceInterface +import SwiftUI + +struct AccountView: View { + + // MARK: - Properties + + @State private var viewModel: AccountViewModel + @Environment(\.viewModelFactory) private var viewModelFactory + + // MARK: - Lifecycle + + init( + viewModel: AccountViewModel + ) { + self.viewModel = viewModel + } + + // MARK: - Public + + var body: some View { + makeAccountView() + } + + // MARK: - Private + + @ViewBuilder + private func makeAccountView() -> some View { + switch viewModel.account { + case .notSynchronized: + EmptyView() + case let .account(current): + makeAccountDetailView( + currentAccount: current + ) + } + } + + @ViewBuilder + private func makeAccountDetailView( + currentAccount: CurrentAccount + ) -> some View { + let viewModel = viewModelFactory + .makeAccountDetailsViewModel( + currentAccount: currentAccount + ) + + AccountDetailsView( + viewModel: viewModel + ) + } +} + +// MARK: - Preview + +#if DEBUG + + #Preview("No account") { + AccountView( + viewModel: AccountViewModelMother + .makeAccountViewModel( + account: .notSynchronized + ) + ) + .frame(width: 420) + } + + #Preview("With account") { + let environment = AccountEnvironmentMother.makeAccountEnvironment() + + AccountView( + viewModel: AccountViewModelMother + .makeAccountViewModel( + account: .account( + current: CurrentAccountMother.makeCurrent() + ) + ) + ) + .frame(width: 420) + .environment(\.viewModelFactory, environment.viewModelFactory) + } + +#endif diff --git a/Packages/Account/Sources/Account/Views/Account/AccountViewModel.swift b/Packages/Account/Sources/Account/Views/Account/AccountViewModel.swift new file mode 100644 index 0000000..9e6034a --- /dev/null +++ b/Packages/Account/Sources/Account/Views/Account/AccountViewModel.swift @@ -0,0 +1,42 @@ +import Observation +import SessionServiceInterface + +@MainActor +@Observable +final class AccountViewModel { + + // MARK: - Properties + + private(set) var account: Account = .notSynchronized + + private let sessionService: any SessionServiceProtocol + + @ObservationIgnored private var observationTask: Task? + + // MARK: - Lifecycle + + init( + sessionService: any SessionServiceProtocol + ) { + self.sessionService = sessionService + + setUpObservers() + } + + deinit { + observationTask?.cancel() + } + + // MARK: - Private + + private func setUpObservers() { + observationTask = Task { [weak self] in + guard let self else { return } + for await account in sessionService.observeAccount() { + await MainActor.run { + self.account = account + } + } + } + } +} diff --git a/Packages/Account/Sources/Account/Views/AccountDetails/AccountDetailsView.swift b/Packages/Account/Sources/Account/Views/AccountDetails/AccountDetailsView.swift new file mode 100644 index 0000000..5ed5664 --- /dev/null +++ b/Packages/Account/Sources/Account/Views/AccountDetails/AccountDetailsView.swift @@ -0,0 +1,147 @@ +import DesignSystem +import SessionServiceInterface +import SwiftUI + +struct AccountDetailsView: View { + + // MARK: - Properties + + @State private var viewModel: AccountDetailsViewModel + + // MARK: - Lifecycle + + init( + viewModel: AccountDetailsViewModel + ) { + self.viewModel = viewModel + } + + // MARK: - Public + + var body: some View { + Form { + makeHeaderView() + makeAddressesView() + } + .scrollContentBackground(.hidden) + .padding(8) + .formStyle(.columns) + } + + // MARK: - Private + + @ViewBuilder + private func makeHeaderView() -> some View { + let registrationDate = viewModel + .currentAccount + .creation + .formatted( + date: .long, + time: .omitted + ) + + Section("Account") { + HStack { + VStack(alignment: .leading) { + Text(viewModel.currentAccount.name) + .bold() + .foregroundColor(.primary) + + Text(viewModel.currentAccount.email) + .foregroundColor(.secondary) + + Text("Registered \(registrationDate)") + .foregroundColor(.secondary) + } + } + .foregroundColor(.black) + .frame(maxWidth: .infinity, alignment: .leading) + .card(.omgBackground) + } + } + + @ViewBuilder + private func makeAddressesView() -> some View { + Section("Addresses") { + List { + ForEach(viewModel.currentAccount.addresses, id: \.address) { address in + HStack { + AvatarView(address: address.address) + makeAddressView(address: address) + } + } + } + } + } + + @ViewBuilder + private func makeAddressView( + address: CurrentAccount.Address + ) -> some View { + let registrationDate = address + .creation + .formatted( + .relative( + presentation: .named, + unitsStyle: .wide + ) + ) + + let formattedExpire = if let expire = address.expire { + "Expires \(expire.formatted(.relative(presentation: .named, unitsStyle: .wide)))" + } else { + "🌟 Lifetime address" + } + + VStack(alignment: .leading) { + HStack { + VStack(alignment: .leading) { + Text(address.address) + .bold() + .foregroundColor(.primary) + + Text("Registered \(registrationDate)") + .font(.subheadline) + .foregroundColor(.secondary) + + Text(formattedExpire) + .font(.subheadline) + .foregroundColor(.secondary) + } + + Spacer() + + if address.address == viewModel.selectedAddress { + Text("Current") + .textCase(.uppercase) + .font(.subheadline) + } else { + Button { + withAnimation { + viewModel.selectAddress(address.address) + } + } label: { + Text("Use This") + .textCase(.uppercase) + .font(.subheadline) + } + .help("Switch to this address") + .buttonStyle(.borderedProminent) + } + } + } + } +} + +// MARK: - Preview + +#if DEBUG + + #Preview { + AccountDetailsView( + viewModel: AccountDetailsViewModelMother.makeAccountDetailsViewModel() + ) + .frame(width: 420) + } + +#endif diff --git a/Packages/Account/Sources/Account/Views/AccountDetails/AccountDetailsViewModel.swift b/Packages/Account/Sources/Account/Views/AccountDetails/AccountDetailsViewModel.swift new file mode 100644 index 0000000..04651aa --- /dev/null +++ b/Packages/Account/Sources/Account/Views/AccountDetails/AccountDetailsViewModel.swift @@ -0,0 +1,66 @@ +import Foundation +import Observation +import SessionServiceInterface + +@MainActor +@Observable +final class AccountDetailsViewModel { + + // MARK: - Properties + + let currentAccount: CurrentAccount + private(set) var selectedAddress: String? + + private let sessionService: any SessionServiceProtocol + + @ObservationIgnored private var observationTask: Task? + + // MARK: - Lifecycle + + init( + currentAccount: CurrentAccount, + sessionService: any SessionServiceProtocol + ) { + self.currentAccount = currentAccount + self.sessionService = sessionService + + setUpObservers() + } + + deinit { + observationTask?.cancel() + } + + // MARK: - Public + + func selectAddress( + _ address: String + ) { + Task { + await sessionService.setSelectedAddress( + address + ) + } + } + + // MARK: - Private + + private func setUpObservers() { + observationTask = Task { [weak self] in + guard let self else { return } + + for await address in sessionService.observeAddress() { + let selected: String? = switch address { + case let .address(current): + current + default: + nil + } + + await MainActor.run { + self.selectedAddress = selected + } + } + } + } +} diff --git a/Packages/Account/Sources/Account/Views/App/AccountApp.swift b/Packages/Account/Sources/Account/Views/App/AccountApp.swift new file mode 100644 index 0000000..945af1f --- /dev/null +++ b/Packages/Account/Sources/Account/Views/App/AccountApp.swift @@ -0,0 +1,30 @@ +import SwiftUI + +/// Main view for the Account feature. +/// +/// `AccountApp` is the root view that displays account information including +/// user details, addresses, and account management capabilities. It coordinates +/// the account feature's UI components and view models. +public struct AccountApp: View { + + // MARK: - Properties + + @State private var viewModel: AccountAppViewModel + @Environment(\.viewModelFactory) private var viewModelFactory + + // MARK: - Lifecycle + + init( + viewModel: AccountAppViewModel + ) { + self.viewModel = viewModel + } + + // MARK: - Public + + public var body: some View { + AccountView( + viewModel: viewModelFactory.makeAccountViewModel() + ) + } +} diff --git a/Packages/Account/Sources/Account/Views/App/AccountAppViewModel.swift b/Packages/Account/Sources/Account/Views/App/AccountAppViewModel.swift new file mode 100644 index 0000000..8c8ae6b --- /dev/null +++ b/Packages/Account/Sources/Account/Views/App/AccountAppViewModel.swift @@ -0,0 +1,5 @@ +import Observation + +@MainActor +@Observable +final class AccountAppViewModel {} diff --git a/Packages/AccountUpdate/.gitignore b/Packages/AccountUpdate/.gitignore new file mode 100644 index 0000000..3b29812 --- /dev/null +++ b/Packages/AccountUpdate/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Packages/AccountUpdate/Package.swift b/Packages/AccountUpdate/Package.swift new file mode 100644 index 0000000..5ba19ce --- /dev/null +++ b/Packages/AccountUpdate/Package.swift @@ -0,0 +1,72 @@ +// swift-tools-version: 6.2 + +import PackageDescription + +let package = Package( + name: "AccountUpdate", + platforms: [ + .macOS(.v15), + .iOS(.v18) + ], + products: [ + .library( + name: "AccountUpdateService", + targets: ["AccountUpdateService"] + ) + ], + dependencies: [ + .package( + name: "SessionService", + path: "../SessionService" + ), + .package( + name: "OMGAPI", + path: "../OMGAPI" + ), + .package( + name: "AuthSession", + path: "../AuthSession" + ), + .package( + url: "https://github.com/otaviocc/MicroContainer.git", + from: "0.0.6" + ), + .package( + url: "https://github.com/apple/swift-async-algorithms", + from: "1.0.0" + ) + ], + targets: [ + .target( + name: "AccountUpdateService", + dependencies: [ + "AccountUpdateNetworkService", + "AccountUpdatePersistenceService", + "AccountUpdateRepository", + "MicroContainer" + ] + ), + .target( + name: "AccountUpdateNetworkService", + dependencies: [ + "OMGAPI" + ] + ), + .target( + name: "AccountUpdatePersistenceService", + dependencies: [ + .product(name: "AuthSessionServiceInterface", package: "AuthSession"), + .product(name: "SessionServiceInterface", package: "SessionService") + ] + ), + .target( + name: "AccountUpdateRepository", + dependencies: [ + .product(name: "AuthSessionServiceInterface", package: "AuthSession"), + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), + "AccountUpdateNetworkService", + "AccountUpdatePersistenceService" + ] + ) + ] +) diff --git a/Packages/AccountUpdate/Sources/AccountUpdateNetworkService/AccountUpdateNetworkService.swift b/Packages/AccountUpdate/Sources/AccountUpdateNetworkService/AccountUpdateNetworkService.swift new file mode 100644 index 0000000..78fd1a6 --- /dev/null +++ b/Packages/AccountUpdate/Sources/AccountUpdateNetworkService/AccountUpdateNetworkService.swift @@ -0,0 +1,101 @@ +import Foundation +import MicroClient +import OMGAPI + +/// A protocol for network operations related to account information updates. +/// +/// This protocol defines the interface for fetching account information and associated +/// addresses from the remote server. It provides the network layer functionality needed +/// to keep local account information synchronized with the server state. +/// +/// The protocol supports fetching both core account information (name, email, creation date) +/// and associated addresses (domains) with their registration and expiration details. +/// This information is typically used to populate user profile displays and manage +/// domain/address ownership information. +public protocol AccountUpdateNetworkServiceProtocol: Sendable { + + /// Fetches the current account information from the server. + /// + /// This method retrieves the authenticated user's account details including + /// their name, email address, and account creation date. The information + /// is fetched from the server's account information endpoint. + /// + /// - Returns: An `AccountResponse` containing the user's account information. + /// - Throws: Network errors, authentication errors, or API errors if the fetch fails. + func fetchAccount() async throws -> AccountResponse + + /// Fetches all addresses associated with the current account. + /// + /// This method retrieves the list of addresses that are registered + /// to the authenticated user's account. Each address includes registration + /// and expiration dates, providing complete ownership information. + /// + /// - Returns: An array of `AddressResponse` objects representing the user's addresses. + /// - Throws: Network errors, authentication errors, or API errors if the fetch fails. + func fetchAddresses() async throws -> [AddressResponse] +} + +actor AccountUpdateNetworkService: AccountUpdateNetworkServiceProtocol { + + // MARK: - Properties + + private let networkClient: NetworkClientProtocol + + // MARK: - Lifecycle + + init( + networkClient: NetworkClientProtocol + ) { + self.networkClient = networkClient + } + + // MARK: - Public + + func fetchAccount() async throws -> AccountResponse { + let accountResponse = try await networkClient.run( + AccountRequestFactory.makeAccountInformationRequest() + ) + + return AccountResponse( + response: accountResponse.value.response + ) + } + + func fetchAddresses() async throws -> [AddressResponse] { + let addressesResponse = try await networkClient.run( + AccountRequestFactory.makeAccountAddressesRequest() + ) + + return addressesResponse.value.response.map(AddressResponse.init) + } +} + +// MARK: - Private + +private extension AccountResponse { + + /// Initializes the `AccountResponse` model from the network response + /// model, so that the client doesn't depend on network models. + /// - Parameter response: The network model to be mapped. + init( + response: AccountInformationResponse.Response + ) { + name = response.name + email = response.email + unixEpochTime = response.created.unixEpochTime + } +} + +private extension AddressResponse { + + /// Initializes the `AddressResponse` model from the network response + /// model, so that the client doesn't depend on network models. + /// - Parameter response: The network model to be mapped. + init( + response: AccountAddressesResponse.AccountAddressResponse + ) { + address = response.address + unixEpochTime = response.registration.unixEpochTime + expireUnixEpochTime = response.expiration.unixEpochTime + } +} diff --git a/Packages/AccountUpdate/Sources/AccountUpdateNetworkService/Factories/AccountUpdateNetworkServiceFactory.swift b/Packages/AccountUpdate/Sources/AccountUpdateNetworkService/Factories/AccountUpdateNetworkServiceFactory.swift new file mode 100644 index 0000000..0eb2085 --- /dev/null +++ b/Packages/AccountUpdate/Sources/AccountUpdateNetworkService/Factories/AccountUpdateNetworkServiceFactory.swift @@ -0,0 +1,57 @@ +import MicroClient + +/// A protocol for creating account update network service instances. +/// +/// This protocol defines the factory interface for creating properly configured +/// account update network services with their required HTTP client dependencies. +/// The factory pattern abstracts the complex initialization of network services +/// and enables dependency injection of different network client implementations. +/// +/// Account update network services handle HTTP requests for account-related operations +/// including profile updates, settings modifications, and account information retrieval. +/// Implementations should configure the service with appropriate network clients +/// for API communication with account management endpoints. +/// +/// ## Usage Example +/// ```swift +/// let factory: AccountUpdateNetworkServiceFactoryProtocol = AccountUpdateNetworkServiceFactory() +/// let service = factory.makeAccountUpdateNetworkService(networkClient: networkClient) +/// ``` +public protocol AccountUpdateNetworkServiceFactoryProtocol { + + /// Creates a new account update network service instance. + /// + /// This method constructs a fully configured account update network service with + /// the provided HTTP client. The service handles account-related network operations + /// including profile updates, settings changes, and account information synchronization. + /// + /// The created service provides: + /// - Account profile update operations via HTTP APIs + /// - Account settings modification and retrieval + /// - User preference synchronization with remote endpoints + /// - Account information validation and submission + /// - Proper error handling for network failures and validation errors + /// + /// - Parameter networkClient: The network client used to perform HTTP requests. + /// - Returns: A configured `AccountUpdateNetworkServiceProtocol` instance ready for use. + func makeAccountUpdateNetworkService( + networkClient: NetworkClientProtocol + ) -> AccountUpdateNetworkServiceProtocol +} + +public struct AccountUpdateNetworkServiceFactory: AccountUpdateNetworkServiceFactoryProtocol { + + // MARK: - Lifecycle + + public init() {} + + // MARK: - Public + + public func makeAccountUpdateNetworkService( + networkClient: NetworkClientProtocol + ) -> AccountUpdateNetworkServiceProtocol { + AccountUpdateNetworkService( + networkClient: networkClient + ) + } +} diff --git a/Packages/AccountUpdate/Sources/AccountUpdateNetworkService/Models/AccountResponse.swift b/Packages/AccountUpdate/Sources/AccountUpdateNetworkService/Models/AccountResponse.swift new file mode 100644 index 0000000..d973f45 --- /dev/null +++ b/Packages/AccountUpdate/Sources/AccountUpdateNetworkService/Models/AccountResponse.swift @@ -0,0 +1,55 @@ +/// A data transfer object representing account information from the network API. +/// +/// This model serves as the network layer's representation of user account data, +/// specifically designed to match the API response format. It acts as an intermediary +/// between the raw network response and the application's domain models. +/// +/// The `AccountResponse` uses Unix epoch time for the creation timestamp to maintain +/// compatibility with the API format, while the application layer converts this to +/// Foundation's Date type for easier manipulation. +/// +/// ## Usage Example +/// ```swift +/// // Typically created by the network service when parsing API responses +/// let accountResponse = AccountResponse( +/// email: "user@example.com", +/// name: "John Doe", +/// unixEpochTime: 1640995200 +/// ) +/// +/// // Converted to domain model by repository layer +/// let account = Account( +/// name: accountResponse.name, +/// email: accountResponse.email, +/// creation: Date(timeIntervalSince1970: Double(accountResponse.unixEpochTime)), +/// addresses: [] +/// ) +/// ``` +/// +/// ## Data Flow +/// AccountResponse flows through the system as follows: +/// 1. Network service receives raw API response +/// 2. Response is parsed into AccountResponse DTO +/// 3. Repository layer converts to domain Account model +/// 4. Domain model is stored in session state +public struct AccountResponse: Sendable { + + /// The user's email address as returned by the API. + /// + /// This represents the primary email associated with the user's account + /// and is used for identification and communication purposes. + public let email: String + + /// The user's display name as returned by the API. + /// + /// This is how the user identifies themselves within the service + /// and appears in various UI elements throughout the application. + public let name: String + + /// The account creation timestamp in Unix epoch time format. + /// + /// This value represents the number of seconds since January 1, 1970 UTC + /// when the user's account was originally created. The repository layer + /// converts this to Foundation's Date type for use in the application. + public let unixEpochTime: Int +} diff --git a/Packages/AccountUpdate/Sources/AccountUpdateNetworkService/Models/AddressResponse.swift b/Packages/AccountUpdate/Sources/AccountUpdateNetworkService/Models/AddressResponse.swift new file mode 100644 index 0000000..26e2059 --- /dev/null +++ b/Packages/AccountUpdate/Sources/AccountUpdateNetworkService/Models/AddressResponse.swift @@ -0,0 +1,67 @@ +/// A data transfer object representing address information from the network API. +/// +/// This model serves as the network layer's representation of user address data, +/// specifically designed to match the API response format for address listings. It acts +/// as an intermediary between the raw network response and the application's domain models. +/// +/// The `AddressResponse` uses Unix epoch time for both registration and expiration +/// timestamps to maintain compatibility with the API format, while the application +/// layer converts these to Foundation's Date type for easier date manipulation and +/// comparison operations. +/// +/// ## Usage Example +/// ```swift +/// // Regular address with expiration date +/// let temporaryAddress = AddressResponse( +/// address: "alice", +/// unixEpochTime: 1640995200, +/// expireUnixEpochTime: 1672531200 +/// ) +/// +/// // Lifetime address with no expiration +/// let lifetimeAddress = AddressResponse( +/// address: "lifetime-user", +/// unixEpochTime: 1640995200, +/// expireUnixEpochTime: nil +/// ) +/// +/// // Converted to domain model by repository layer +/// let address = Account.Address( +/// address: addressResponse.address, +/// creation: Date(timeIntervalSince1970: Double(addressResponse.unixEpochTime)), +/// expire: addressResponse.expireUnixEpochTime.map { Date(timeIntervalSince1970: Double($0)) } +/// ) +/// ``` +/// +/// ## Data Flow +/// AddressResponse flows through the system as follows: +/// 1. Network service receives raw API response array +/// 2. Each response item is parsed into AddressResponse DTO +/// 3. Repository layer converts to domain Account.Address models +/// 4. Domain models are included in Account and stored in session state +public struct AddressResponse: Sendable { + + /// The address string as returned by the API (e.g., "alice"). + /// + /// This represents the username that the user can use for communication, + /// web presence, or other services provided by the platform. + public let address: String + + /// The address registration timestamp in Unix epoch time format. + /// + /// This value represents the number of seconds since January 1, 1970 UTC + /// when the address was originally registered by the user. The repository + /// layer converts this to Foundation's Date type for use in the application. + public let unixEpochTime: Int + + /// The address expiration timestamp in Unix epoch time format, if the address has an expiration date. + /// + /// This optional value represents the number of seconds since January 1, 1970 UTC + /// when the address registration expires. A `nil` value indicates a lifetime address + /// that never expires. This information is crucial for renewal management and + /// determining address availability status. The repository layer converts this to + /// Foundation's Date type for easier comparison with the current date. + /// + /// - Note: Lifetime addresses return `nil` for this property and do not require renewal. + public let expireUnixEpochTime: Int? +} diff --git a/Packages/AccountUpdate/Sources/AccountUpdatePersistenceService/AccountUpdatePersistenceService.swift b/Packages/AccountUpdate/Sources/AccountUpdatePersistenceService/AccountUpdatePersistenceService.swift new file mode 100644 index 0000000..cd42fe7 --- /dev/null +++ b/Packages/AccountUpdate/Sources/AccountUpdatePersistenceService/AccountUpdatePersistenceService.swift @@ -0,0 +1,110 @@ +import AuthSessionServiceInterface +import SessionServiceInterface + +/// A protocol for managing account information persistence operations. +/// +/// This protocol defines the interface for storing account information in the application's +/// session state. Unlike traditional persistence services that use databases or file storage, +/// this service manages account data within the active session through the SessionService. +/// +/// The service handles the integration between fetched account data and the session management +/// system, ensuring that current account information is available throughout the application. +/// It also manages automatic cleanup of account data when users log out. +/// +/// All storage operations are performed on the main actor to ensure thread safety with +/// session state updates and UI synchronization. +public protocol AccountUpdatePersistenceServiceProtocol: Sendable { + + /// Stores account information in the current session. + /// + /// This method persists the provided account information by updating the current + /// session with the account details. The account data becomes available throughout + /// the application via the session service and can be accessed by UI components + /// and other services that need account information. + /// + /// The account information includes user details (name, email) and associated + /// addresses with their registration and expiration dates. + /// + /// - Parameter account: The account information to store in the current session. + /// - Throws: Session-related errors if the storage operation fails. + @MainActor + func storeAccount(_ account: Account) async throws +} + +actor AccountUpdatePersistenceService: AccountUpdatePersistenceServiceProtocol { + + // MARK: - Properties + + private let sessionService: any SessionServiceProtocol + private let authSessionService: any AuthSessionServiceProtocol + private var logoutObservationTask: Task? + + // MARK: - Lifecycle + + init( + sessionService: any SessionServiceProtocol, + authSessionService: any AuthSessionServiceProtocol + ) { + self.sessionService = sessionService + self.authSessionService = authSessionService + + Task { await setUpObservers() } + } + + deinit { + logoutObservationTask?.cancel() + } + + // MARK: - Public + + @MainActor + func storeAccount( + _ account: Account + ) async throws { + await sessionService + .setCurrentAccount( + .init(account: account) + ) + } + + // MARK: - Private + + private func setUpObservers() async { + logoutObservationTask = Task { + for await _ in authSessionService.observeLogoutEvents() { + await sessionService.clearSession() + } + } + } +} + +// MARK: - Private + +private extension CurrentAccount { + + /// Initializes `CurrentAccount` from an `Account`. + /// + /// This extension is file private since no other part of the codebase + /// needs to know how to map between these two types. + /// + /// - Parameter account: The account to be mapped. + init( + account: Account + ) { + let addresses = account.addresses + .map { address in + CurrentAccount.Address( + address: address.address, + creation: address.creation, + expire: address.expire + ) + } + + self.init( + name: account.name, + email: account.email, + creation: account.creation, + addresses: addresses + ) + } +} diff --git a/Packages/AccountUpdate/Sources/AccountUpdatePersistenceService/Factories/AccountUpdatePersistenceServiceFactory.swift b/Packages/AccountUpdate/Sources/AccountUpdatePersistenceService/Factories/AccountUpdatePersistenceServiceFactory.swift new file mode 100644 index 0000000..0be4caa --- /dev/null +++ b/Packages/AccountUpdate/Sources/AccountUpdatePersistenceService/Factories/AccountUpdatePersistenceServiceFactory.swift @@ -0,0 +1,67 @@ +import AuthSessionServiceInterface +import SessionServiceInterface + +/// A protocol for creating account update persistence service instances. +/// +/// This protocol defines the factory interface for creating properly configured +/// account update persistence services with their required session service dependencies. +/// The factory pattern abstracts the complex initialization of persistence services +/// and enables dependency injection of different session service implementations. +/// +/// Account update persistence services handle local storage and caching of account +/// update operations, temporary data management during account modification workflows, +/// and integration with session state for user context. Implementations should configure +/// the service with appropriate session services for state management and user context. +/// +/// ## Usage Example +/// ```swift +/// let factory: AccountUpdatePersistenceServiceFactoryProtocol = AccountUpdatePersistenceServiceFactory() +/// let service = factory.makeAccountUpdatePersistenceService( +/// sessionService: sessionService, +/// authSessionService: authSessionService +/// ) +/// ``` +public protocol AccountUpdatePersistenceServiceFactoryProtocol { + + /// Creates a new account update persistence service instance. + /// + /// This method constructs a fully configured account update persistence service + /// with the provided session services. The persistence service handles local + /// storage of account update operations, draft management, and integration with + /// user session state for context-aware account modifications. + /// + /// The created service provides: + /// - Local storage of account update drafts and pending changes + /// - Session-aware account modification workflows + /// - Integration with user authentication state for secure operations + /// - Temporary data management during multi-step account updates + /// - Cache management for account-related data and preferences + /// + /// - Parameters: + /// - sessionService: The session service for user state management. + /// - authSessionService: The authentication session service for user context. + /// - Returns: A configured `AccountUpdatePersistenceServiceProtocol` instance ready for use. + func makeAccountUpdatePersistenceService( + sessionService: any SessionServiceProtocol, + authSessionService: any AuthSessionServiceProtocol + ) -> AccountUpdatePersistenceServiceProtocol +} + +public final class AccountUpdatePersistenceServiceFactory: AccountUpdatePersistenceServiceFactoryProtocol { + + // MARK: - Lifecycle + + public init() {} + + // MARK: - Public + + public func makeAccountUpdatePersistenceService( + sessionService: any SessionServiceProtocol, + authSessionService: any AuthSessionServiceProtocol + ) -> AccountUpdatePersistenceServiceProtocol { + AccountUpdatePersistenceService( + sessionService: sessionService, + authSessionService: authSessionService + ) + } +} diff --git a/Packages/AccountUpdate/Sources/AccountUpdatePersistenceService/Models/Account.swift b/Packages/AccountUpdate/Sources/AccountUpdatePersistenceService/Models/Account.swift new file mode 100644 index 0000000..a38aebe --- /dev/null +++ b/Packages/AccountUpdate/Sources/AccountUpdatePersistenceService/Models/Account.swift @@ -0,0 +1,187 @@ +import Foundation + +/// Represents complete account information including user details and associated addresses. +/// +/// This model serves as the central data structure for user account information within +/// the application's session state. It combines core user details (name, email, creation date) +/// with all associated addresses/domains that belong to the account. +/// +/// The `Account` struct is designed to be immutable and thread-safe (Sendable), making it +/// suitable for use across different actors and concurrent operations. It's also Codable +/// to support serialization for network communication and potential caching scenarios. +/// +/// ## Usage Example +/// ```swift +/// let account = Account( +/// name: "John Doe", +/// email: "john@example.com", +/// creation: Date(), +/// addresses: [ +/// // Regular address with expiration +/// Account.Address( +/// address: "john", +/// creation: Date(), +/// expire: Calendar.current.date(byAdding: .year, value: 1, to: Date())! +/// ), +/// // Lifetime address with no expiration +/// Account.Address( +/// address: "lifetime-john", +/// creation: Date(), +/// expire: nil +/// ) +/// ] +/// ) +/// ``` +/// +/// ## Data Flow +/// Account data flows through the system as follows: +/// 1. Network layer fetches account and address data separately +/// 2. Repository combines the responses into this unified Account model +/// 3. Persistence service stores the account in the session state +/// 4. UI components access the account information through the session service +public struct Account: Codable, Sendable { + + // MARK: - Properties + + /// The user's display name as registered with their account. + /// + /// This is the name that appears in user interface elements and represents + /// how the user identifies themselves within the service. + public let name: String + + /// The user's primary email address associated with their account. + /// + /// This email serves as the primary identifier for the account and is used + /// for communication, account recovery, and authentication purposes. + public let email: String + + /// The date when the user's account was originally created. + /// + /// This timestamp provides historical information about the account age + /// and can be used for analytics, user journey tracking, or feature eligibility. + public let creation: Date + + /// An array of all addresses associated with this account. + /// + /// Each address represents a username that the user owns or has + /// registered through the service. This includes both active and expired addresses, + /// allowing the application to display complete ownership history and manage + /// renewal notifications. + public let addresses: [Address] + + // MARK: - Lifecycle + + /// Creates a new Account instance with the specified user information and addresses. + /// + /// This initializer is typically used by the repository layer when combining + /// network responses into a unified account model, or by test code when creating + /// mock account data. + /// + /// - Parameters: + /// - name: The user's display name + /// - email: The user's primary email address + /// - creation: The date when the account was originally created + /// - addresses: An array of addresses/domains associated with this account + public init( + name: String, + email: String, + creation: Date, + addresses: [Account.Address] + ) { + self.name = name + self.email = email + self.creation = creation + self.addresses = addresses + } +} + +public extension Account { + + // MARK: - Nested types + + /// Represents an address associated with a user account. + /// + /// An address in this context represents a username that the user has + /// registered through the service (e.g., "alice"). Each address includes + /// registration and expiration information, allowing the application to track ownership + /// status and manage renewal workflows. + /// + /// The Address struct is designed to be immutable and thread-safe, making it suitable + /// for use in concurrent operations and actor-based architectures. It's also Codable + /// to support serialization alongside its parent Account model. + /// + /// ## Usage Example + /// ```swift + /// // Regular address with expiration + /// let regularAddress = Account.Address( + /// address: "alice", + /// creation: Date(), + /// expire: Calendar.current.date(byAdding: .year, value: 1, to: Date())! + /// ) + /// + /// // Lifetime address with no expiration + /// let lifetimeAddress = Account.Address( + /// address: "lifetime-alice", + /// creation: Date(), + /// expire: nil + /// ) + /// + /// // Check if address is still valid (lifetime addresses are always valid) + /// let isActive = regularAddress.expire?.compare(Date()) == .orderedDescending || regularAddress.expire == nil + /// ``` + /// + /// ## Address Lifecycle + /// 1. Address is registered by the user through the service + /// 2. Registration information is fetched from the API + /// 3. Address data is stored as part of the user's account + /// 4. Application can display ownership status and renewal reminders based on expiration + struct Address: Codable, Sendable { + + // MARK: - Properties + + /// The actual address string (e.g., "alice"). + /// + /// This represents the username that the user can use for communication, + /// web presence, or other services provided by the platform. + public let address: String + + /// The date when this address was originally registered. + /// + /// This timestamp provides historical information about when the user + /// first acquired this address, useful for account history and analytics. + public let creation: Date + + /// The date when this address registration expires, if it has an expiration date. + /// + /// This optional timestamp is crucial for renewal management, allowing the application + /// to display warnings, send notifications, or restrict features as the + /// expiration date approaches. A `nil` value indicates a lifetime address that never + /// expires and does not require renewal. Regular addresses past their expiration + /// date may become unavailable for use. + /// + /// - Note: Lifetime addresses return `nil` and remain active indefinitely. + public let expire: Date? + + // MARK: - Lifecycle + + /// Creates a new Address instance with the specified address information. + /// + /// This initializer is typically used by the repository layer when mapping + /// network response data into domain models, or by test code when creating + /// mock address data. + /// + /// - Parameters: + /// - address: The address string (e.g., "alice") + /// - creation: The date when this address was registered + /// - expire: The date when this address registration expires + public init( + address: String, + creation: Date, + expire: Date? + ) { + self.address = address + self.creation = creation + self.expire = expire + } + } +} diff --git a/Packages/AccountUpdate/Sources/AccountUpdateRepository/AccountUpdateRepository.swift b/Packages/AccountUpdate/Sources/AccountUpdateRepository/AccountUpdateRepository.swift new file mode 100644 index 0000000..524949f --- /dev/null +++ b/Packages/AccountUpdate/Sources/AccountUpdateRepository/AccountUpdateRepository.swift @@ -0,0 +1,155 @@ +import AccountUpdateNetworkService +import AccountUpdatePersistenceService +import AsyncAlgorithms +import AuthSessionServiceInterface +import Foundation + +/// A repository protocol for managing automatic account information updates. +/// +/// This protocol defines the interface for coordinating automatic synchronization of +/// account information from the remote server to local storage. The repository handles +/// the complex orchestration of periodic updates, authentication state monitoring, +/// and data persistence. +/// +/// Unlike other repositories that provide explicit methods for data operations, this +/// repository operates automatically in the background, triggered by authentication +/// events and periodic timers. It ensures that account information stays current +/// without requiring manual intervention from higher-level components. +/// +/// The repository follows the repository pattern while providing a completely automated +/// approach to account data synchronization, handling network fetching, data +/// transformation, and secure storage seamlessly. +public protocol AccountUpdateRepositoryProtocol: Sendable {} + +actor AccountUpdateRepository: AccountUpdateRepositoryProtocol { + + // MARK: - Properties + + private let networkService: AccountUpdateNetworkServiceProtocol + private let persistenceService: AccountUpdatePersistenceServiceProtocol + private let authSessionService: any AuthSessionServiceProtocol + private var loginObservationTask: Task? + private var timerObservationTask: Task? + + // MARK: - Lifecycle + + init( + networkService: AccountUpdateNetworkServiceProtocol, + persistenceService: AccountUpdatePersistenceServiceProtocol, + authSessionService: any AuthSessionServiceProtocol + ) { + self.networkService = networkService + self.persistenceService = persistenceService + self.authSessionService = authSessionService + + Task { await setUpObservers() } + } + + deinit { + loginObservationTask?.cancel() + timerObservationTask?.cancel() + } + + // MARK: - Private + + private func setUpObservers() { + timerObservationTask = Task { [weak self] in + guard let self else { return } + + await fetchAccountInformation() + + for await _ in AsyncTimerSequence(interval: .seconds(3600), clock: .continuous) { + await fetchAccountInformation() + } + } + + loginObservationTask = Task { [weak self] in + guard let self else { return } + + if await authSessionService.isLoggedIn { + await fetchAccountInformation() + } + + for await isLoggedIn in authSessionService.observeLoginState() { + if isLoggedIn { + await fetchAccountInformation() + } + } + } + } + + private func fetchAccountInformation() async { + guard await authSessionService.isLoggedIn else { return } + + do { + let accountResponse = try await networkService.fetchAccount() + let addressesResponse = try await networkService.fetchAddresses() + + let account = Account( + accountResponse: accountResponse, + addressesResponse: addressesResponse + ) + + do { + try await persistenceService.storeAccount(account) + } catch { + print("Failed to persist account: \(error)") + } + } catch { + print("Account update failed: \(error)") + } + } +} + +// MARK: - Private + +private extension Account { + + /// Initializes an `Account` with the data from two network requests: + /// one which returns the account information, and another one which + /// returns the addresses associated with the user account. + /// + /// This initializer handles both regular addresses (with expiration dates) and + /// lifetime addresses (without expiration dates). The optional expiration timestamp + /// from the network response is mapped to an optional Date, with `nil` values + /// indicating lifetime addresses that never expire. + /// + /// This extension is file private since no other part of the codebase + /// needs to know how to map the network response types and the + /// type which is persisted. + /// + /// - Parameters: + /// - accountResponse: The network model for the account information. + /// - addressesResponse: The network model for the addresses information, including optional expiration dates. + init( + accountResponse: AccountResponse, + addressesResponse: [AddressResponse] + ) { + let accountDate = Date( + timeIntervalSince1970: accountResponse.unixEpochTime.doubleValue + ) + + let addresses = addressesResponse.map { addressResponse in + let addressDate = Date( + timeIntervalSince1970: addressResponse.unixEpochTime.doubleValue + ) + + let expireDate = addressResponse.expireUnixEpochTime + .map(Double.init) + .map(Date.init(timeIntervalSince1970:)) + + return Account.Address( + address: addressResponse.address, + creation: addressDate, + expire: expireDate + ) + } + + self.init( + name: accountResponse.name, + email: accountResponse.email, + creation: accountDate, + addresses: addresses + ) + } +} diff --git a/Packages/AccountUpdate/Sources/AccountUpdateRepository/Extensions/Int+Account.swift b/Packages/AccountUpdate/Sources/AccountUpdateRepository/Extensions/Int+Account.swift new file mode 100644 index 0000000..00a63ee --- /dev/null +++ b/Packages/AccountUpdate/Sources/AccountUpdateRepository/Extensions/Int+Account.swift @@ -0,0 +1,7 @@ +extension Int { + + /// Returns a double from integer. + var doubleValue: Double { + .init(self) + } +} diff --git a/Packages/AccountUpdate/Sources/AccountUpdateRepository/Factories/AccountUpdateServiceFactory.swift b/Packages/AccountUpdate/Sources/AccountUpdateRepository/Factories/AccountUpdateServiceFactory.swift new file mode 100644 index 0000000..e5df97a --- /dev/null +++ b/Packages/AccountUpdate/Sources/AccountUpdateRepository/Factories/AccountUpdateServiceFactory.swift @@ -0,0 +1,77 @@ +import AccountUpdateNetworkService +import AccountUpdatePersistenceService +import AuthSessionServiceInterface + +/// A protocol for creating account update repository instances. +/// +/// This protocol defines the factory interface for creating properly configured +/// account update repositories with their required network, persistence, and session +/// service dependencies. The factory pattern abstracts the complex initialization +/// of repositories and enables dependency injection of different service implementations. +/// +/// Account update repositories coordinate between network, persistence, and authentication +/// layers to provide a unified interface for account management operations. They handle +/// account profile updates, settings modifications, data synchronization, and provide +/// seamless integration between remote API calls and local data storage for account +/// information and user preferences. +/// +/// ## Usage Example +/// ```swift +/// let factory: AccountUpdateRepositoryFactoryProtocol = AccountUpdateRepositoryFactory() +/// let repository = factory.makeAccountUpdateRepository( +/// networkService: networkService, +/// persistenceService: persistenceService, +/// authSessionService: authSessionService +/// ) +/// ``` +public protocol AccountUpdateRepositoryFactoryProtocol { + + /// Creates a new account update repository instance. + /// + /// This method constructs a fully configured account update repository with the + /// provided services. The repository coordinates between network, persistence, + /// and authentication services to provide comprehensive account management + /// capabilities including profile updates, settings synchronization, and user + /// context management. + /// + /// The created repository provides: + /// - Account profile updates with optimistic UI updates and rollback support + /// - Settings synchronization between local preferences and remote storage + /// - Draft management for multi-step account modification workflows + /// - Real-time synchronization between local cache and remote account data + /// - User authentication state integration for secure operations + /// - Conflict resolution for concurrent account modifications + /// - Timer-based periodic synchronization for account data freshness + /// + /// - Parameters: + /// - networkService: The network service for remote account update operations. + /// - persistenceService: The persistence service for local data storage and caching. + /// - authSessionService: The authentication session service for user context. + /// - Returns: A configured `AccountUpdateRepositoryProtocol` instance ready for use. + func makeAccountUpdateRepository( + networkService: AccountUpdateNetworkServiceProtocol, + persistenceService: AccountUpdatePersistenceServiceProtocol, + authSessionService: any AuthSessionServiceProtocol + ) -> AccountUpdateRepositoryProtocol +} + +public final class AccountUpdateRepositoryFactory: AccountUpdateRepositoryFactoryProtocol { + + // MARK: - Lifecycle + + public init() {} + + // MARK: - Public + + public func makeAccountUpdateRepository( + networkService: AccountUpdateNetworkServiceProtocol, + persistenceService: AccountUpdatePersistenceServiceProtocol, + authSessionService: any AuthSessionServiceProtocol + ) -> AccountUpdateRepositoryProtocol { + AccountUpdateRepository( + networkService: networkService, + persistenceService: persistenceService, + authSessionService: authSessionService + ) + } +} diff --git a/Packages/AccountUpdate/Sources/AccountUpdateService/AccountUpdateService.swift b/Packages/AccountUpdate/Sources/AccountUpdateService/AccountUpdateService.swift new file mode 100644 index 0000000..50ea752 --- /dev/null +++ b/Packages/AccountUpdate/Sources/AccountUpdateService/AccountUpdateService.swift @@ -0,0 +1,18 @@ +import AccountUpdateRepository +import Observation + +@Observable +public final class AccountUpdateService { + + // MARK: - Properties + + private let updateRepository: AccountUpdateRepositoryProtocol + + // MARK: - Lifecycle + + init( + updateRepository: AccountUpdateRepositoryProtocol + ) { + self.updateRepository = updateRepository + } +} diff --git a/Packages/AccountUpdate/Sources/AccountUpdateService/Environment/AccountUpdateEnvironment.swift b/Packages/AccountUpdate/Sources/AccountUpdateService/Environment/AccountUpdateEnvironment.swift new file mode 100644 index 0000000..5463249 --- /dev/null +++ b/Packages/AccountUpdate/Sources/AccountUpdateService/Environment/AccountUpdateEnvironment.swift @@ -0,0 +1,98 @@ +import AccountUpdateNetworkService +import AccountUpdatePersistenceService +import AccountUpdateRepository +import AuthSessionServiceInterface +import MicroClient +import MicroContainer +import SessionServiceInterface + +struct AccountUpdateEnvironment { + + // MARK: - Properties + + var accountUpdateRepository: AccountUpdateRepositoryProtocol { + container.resolve() + } + + private let container = DependencyContainer() + + // MARK: - Lifecycle + + init( + sessionService: any SessionServiceProtocol, + authSessionService: any AuthSessionServiceProtocol, + networkClient: NetworkClientProtocol + ) { + self.init( + sessionService: sessionService, + authSessionService: authSessionService, + networkClient: networkClient, + networkServiceFactory: AccountUpdateNetworkServiceFactory(), + persistenceServiceFactory: AccountUpdatePersistenceServiceFactory(), + serviceFactory: AccountUpdateRepositoryFactory() + ) + } + + init( + sessionService: any SessionServiceProtocol, + authSessionService: any AuthSessionServiceProtocol, + networkClient: NetworkClientProtocol, + networkServiceFactory: AccountUpdateNetworkServiceFactoryProtocol, + persistenceServiceFactory: AccountUpdatePersistenceServiceFactoryProtocol, + serviceFactory: AccountUpdateRepositoryFactoryProtocol + ) { + container.register( + type: (any SessionServiceProtocol).self, + allocation: .dynamic + ) { _ in + sessionService + } + + container.register( + type: (any AuthSessionServiceProtocol).self, + allocation: .dynamic + ) { _ in + authSessionService + } + + container.register( + type: NetworkClientProtocol.self, + allocation: .dynamic + ) { _ in + networkClient + } + + container.register( + type: AccountUpdateNetworkServiceProtocol.self, + allocation: .static + ) { container in + networkServiceFactory + .makeAccountUpdateNetworkService( + networkClient: container.resolve() + ) + } + + container.register( + type: AccountUpdatePersistenceServiceProtocol.self, + allocation: .static + ) { container in + persistenceServiceFactory + .makeAccountUpdatePersistenceService( + sessionService: container.resolve(), + authSessionService: container.resolve() + ) + } + + container.register( + type: AccountUpdateRepositoryProtocol.self, + allocation: .static + ) { container in + serviceFactory + .makeAccountUpdateRepository( + networkService: container.resolve(), + persistenceService: container.resolve(), + authSessionService: container.resolve() + ) + } + } +} diff --git a/Packages/AccountUpdate/Sources/AccountUpdateService/Factories/AccountUpdateAppFactory.swift b/Packages/AccountUpdate/Sources/AccountUpdateService/Factories/AccountUpdateAppFactory.swift new file mode 100644 index 0000000..ca16e96 --- /dev/null +++ b/Packages/AccountUpdate/Sources/AccountUpdateService/Factories/AccountUpdateAppFactory.swift @@ -0,0 +1,57 @@ +import AccountUpdateRepository +import AuthSessionServiceInterface +import MicroClient +import SessionServiceInterface + +/// Factory responsible for creating account update services and dependencies. +/// +/// `AccountUpdateAppFactory` manages the account update functionality including +/// fetching and syncing current account information. It initializes the account update +/// environment with required dependencies and provides methods to create account update services. +/// +/// ## Usage +/// +/// ```swift +/// let factory = AccountUpdateAppFactory( +/// sessionService: sessionService, +/// authSessionService: authSession, +/// networkClient: client +/// ) +/// +/// let updateService = factory.makeAccountUpdateService() +/// ``` +public final class AccountUpdateAppFactory { + + // MARK: - Properties + + private let environment: AccountUpdateEnvironment + + // MARK: - Lifecycle + + public init( + sessionService: any SessionServiceProtocol, + authSessionService: any AuthSessionServiceProtocol, + networkClient: NetworkClientProtocol + ) { + environment = .init( + sessionService: sessionService, + authSessionService: authSessionService, + networkClient: networkClient + ) + } + + // MARK: - Public + + /// Creates an account update service. + /// + /// This method constructs an account update service with all necessary dependencies + /// injected. The service handles fetching current account information from the API + /// and updating the session with the latest account data. + /// + /// - Returns: A configured account update service ready for use. + public func makeAccountUpdateService() -> AccountUpdateService { + .init( + updateRepository: environment.accountUpdateRepository + ) + } +} diff --git a/Packages/Auth/.gitignore b/Packages/Auth/.gitignore new file mode 100644 index 0000000..3b29812 --- /dev/null +++ b/Packages/Auth/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Packages/Auth/Package.swift b/Packages/Auth/Package.swift new file mode 100644 index 0000000..b0c62b8 --- /dev/null +++ b/Packages/Auth/Package.swift @@ -0,0 +1,80 @@ +// swift-tools-version: 6.2 + +import PackageDescription + +let package = Package( + name: "Auth", + platforms: [ + .macOS(.v15), + .iOS(.v18) + ], + products: [ + .library( + name: "Auth", + targets: ["Auth"] + ) + ], + dependencies: [ + .package( + name: "DesignSystem", + path: "../DesignSystem" + ), + .package( + name: "OMGAPI", + path: "../OMGAPI" + ), + .package( + name: "AuthSession", + path: "../AuthSession" + ), + .package( + url: "https://github.com/otaviocc/MicroClient.git", + from: "0.0.27" + ), + .package( + url: "https://github.com/otaviocc/MicroContainer.git", + from: "0.0.6" + ) + ], + targets: [ + .target( + name: "Auth", + dependencies: [ + .product(name: "AuthSessionServiceInterface", package: "AuthSession"), + "AuthRepository", + "DesignSystem", + "MicroContainer" + ] + ), + .target( + name: "AuthNetworkService", + dependencies: ["OMGAPI", "MicroClient"] + ), + .testTarget( + name: "AuthNetworkServiceTests", + dependencies: ["AuthNetworkService"] + ), + .target( + name: "AuthPersistenceService", + dependencies: [ + .product(name: "AuthSessionServiceInterface", package: "AuthSession") + ] + ), + .testTarget( + name: "AuthPersistenceServiceTests", + dependencies: ["AuthPersistenceService"] + ), + .target( + name: "AuthRepository", + dependencies: [ + .product(name: "AuthSessionServiceInterface", package: "AuthSession"), + "AuthNetworkService", + "AuthPersistenceService" + ] + ), + .testTarget( + name: "AuthRepositoryTests", + dependencies: ["AuthRepository"] + ) + ] +) diff --git a/Packages/Auth/Sources/Auth/Environment/AuthEnvironment.swift b/Packages/Auth/Sources/Auth/Environment/AuthEnvironment.swift new file mode 100644 index 0000000..2fdb099 --- /dev/null +++ b/Packages/Auth/Sources/Auth/Environment/AuthEnvironment.swift @@ -0,0 +1,92 @@ +import AuthNetworkService +import AuthPersistenceService +import AuthRepository +import AuthSessionServiceInterface +import MicroClient +import MicroContainer + +struct AuthEnvironment { + + // MARK: - Properties + + var viewModelFactory: ViewModelFactory { container.resolve() } + + private let container = DependencyContainer() + + // MARK: - Lifecycle + + init( + authSessionService: any AuthSessionServiceProtocol, + networkClient: NetworkClientProtocol + ) { + self.init( + repositoryFactory: AuthRepositoryFactory(), + networkServiceFactory: AuthNetworkServiceFactory(), + persistenceServiceFactory: AuthPersistenceServiceFactory(), + authSessionService: authSessionService, + networkClient: networkClient + ) + } + + init( + repositoryFactory: AuthRepositoryFactoryProtocol, + networkServiceFactory: AuthNetworkServiceFactoryProtocol, + persistenceServiceFactory: AuthPersistenceServiceFactoryProtocol, + authSessionService: any AuthSessionServiceProtocol, + networkClient: NetworkClientProtocol + ) { + container.register( + type: (any AuthSessionServiceProtocol).self, + allocation: .dynamic + ) { _ in + authSessionService + } + + container.register( + type: NetworkClientProtocol.self, + allocation: .dynamic + ) { _ in + networkClient + } + + container.register( + type: AuthNetworkServiceProtocol.self, + allocation: .static + ) { container in + networkServiceFactory + .makeAuthNetworkService( + networkClient: container.resolve() + ) + } + + container.register( + type: AuthPersistenceServiceProtocol.self, + allocation: .static + ) { container in + persistenceServiceFactory + .makeAuthPersistenceService( + authSessionService: container.resolve() + ) + } + + container.register( + type: AuthRepositoryProtocol.self, + allocation: .static + ) { container in + repositoryFactory + .makeAuthRepository( + networkService: container.resolve(), + persistenceService: container.resolve() + ) + } + + container.register( + type: ViewModelFactory.self, + allocation: .static + ) { container in + ViewModelFactory( + container: container + ) + } + } +} diff --git a/Packages/Auth/Sources/Auth/Factories/AuthAppFactory.swift b/Packages/Auth/Sources/Auth/Factories/AuthAppFactory.swift new file mode 100644 index 0000000..80d57ca --- /dev/null +++ b/Packages/Auth/Sources/Auth/Factories/AuthAppFactory.swift @@ -0,0 +1,59 @@ +import AuthSessionServiceInterface +import MicroClient +import SwiftUI + +/// Factory responsible for creating the authentication feature and its views. +/// +/// `AuthAppFactory` manages the authentication flow including login and logout functionality. +/// It initializes the authentication environment with required dependencies and provides +/// methods to create fully configured authentication views. +/// +/// ## Usage +/// +/// ```swift +/// let factory = AuthAppFactory( +/// authSessionService: authSession, +/// networkClient: client +/// ) +/// +/// let authView = factory.makeAppView() +/// ``` +public final class AuthAppFactory { + + // MARK: - Properties + + private let environment: AuthEnvironment + + // MARK: - Lifecycle + + public init( + authSessionService: any AuthSessionServiceProtocol, + networkClient: NetworkClientProtocol + ) { + environment = .init( + authSessionService: authSessionService, + networkClient: networkClient + ) + } + + // MARK: - Public + + /// Creates the main authentication view. + /// + /// This method constructs the authentication feature's root view with all necessary + /// dependencies injected. The view handles the complete authentication flow including + /// login, logout, and session management. + /// + /// - Returns: A configured authentication view ready for presentation. + @MainActor + @ViewBuilder + public func makeAppView() -> some View { + let viewModel = environment.viewModelFactory + .makeAuthAppViewModel() + + AuthApp( + viewModel: viewModel + ) + .environment(\.viewModelFactory, environment.viewModelFactory) + } +} diff --git a/Packages/Auth/Sources/Auth/Factories/ViewModelFactory.swift b/Packages/Auth/Sources/Auth/Factories/ViewModelFactory.swift new file mode 100644 index 0000000..cb997e6 --- /dev/null +++ b/Packages/Auth/Sources/Auth/Factories/ViewModelFactory.swift @@ -0,0 +1,56 @@ +import AuthRepository +import Foundation +import MicroContainer +import SwiftUI + +final class ViewModelFactory: Sendable { + + // MARK: - Properties + + private let container: DependencyContainer + + // MARK: - Lifecycle + + init( + container: DependencyContainer + ) { + self.container = container + } + + // MARK: - Public + + @MainActor + func makeAuthAppViewModel() -> AuthAppViewModel { + .init( + authSessionService: container.resolve() + ) + } + + @MainActor + func makeLoginViewModel() -> LoginViewModel { + .init( + repository: container.resolve() + ) + } + + @MainActor + func makeLogoutViewModel() -> LogoutViewModel { + .init( + repository: container.resolve() + ) + } +} + +// MARK: - Environment + +extension EnvironmentValues { + + @Entry var viewModelFactory: ViewModelFactory = .placeholder +} + +extension ViewModelFactory { + + static let placeholder = ViewModelFactory( + container: .init() + ) +} diff --git a/Packages/Auth/Sources/Auth/Fixtures/LoginViewModelMother.swift b/Packages/Auth/Sources/Auth/Fixtures/LoginViewModelMother.swift new file mode 100644 index 0000000..e646a76 --- /dev/null +++ b/Packages/Auth/Sources/Auth/Fixtures/LoginViewModelMother.swift @@ -0,0 +1,31 @@ +#if DEBUG + + import AuthRepository + import Foundation + + enum LoginViewModelMother { + + // MARK: - Nested types + + private final class FakeAuthRepository: AuthRepositoryProtocol { + + func handleDeepLinkURL(_ url: URL) async throws {} + func storeToken(accessToken: String) {} + func removeAccessToken() {} + + func accessToken() async throws -> String { + "some access token" + } + } + + // MARK: - Public + + @MainActor + static func makeLoginViewModel() -> LoginViewModel { + .init( + repository: FakeAuthRepository() + ) + } + } + +#endif diff --git a/Packages/Auth/Sources/Auth/Fixtures/LogoutViewModelMother.swift b/Packages/Auth/Sources/Auth/Fixtures/LogoutViewModelMother.swift new file mode 100644 index 0000000..fd0b2ac --- /dev/null +++ b/Packages/Auth/Sources/Auth/Fixtures/LogoutViewModelMother.swift @@ -0,0 +1,31 @@ +#if DEBUG + + import AuthRepository + import Foundation + + enum LogoutViewModelMother { + + // MARK: - Nested types + + private final class FakeAuthRepository: AuthRepositoryProtocol { + + func handleDeepLinkURL(_ url: URL) async throws {} + func storeToken(accessToken: String) {} + func removeAccessToken() {} + + func accessToken() async throws -> String { + "some access token" + } + } + + // MARK: - Public + + @MainActor + static func makeLogoutViewModel() -> LogoutViewModel { + .init( + repository: FakeAuthRepository() + ) + } + } + +#endif diff --git a/Packages/Auth/Sources/Auth/Views/App/AuthApp.swift b/Packages/Auth/Sources/Auth/Views/App/AuthApp.swift new file mode 100644 index 0000000..48a11ca --- /dev/null +++ b/Packages/Auth/Sources/Auth/Views/App/AuthApp.swift @@ -0,0 +1,36 @@ +import SwiftUI + +/// Main view for the Authentication feature. +/// +/// `AuthApp` is the root view that manages the authentication flow, displaying +/// either the login view when the user is not authenticated or the logout view +/// when authenticated. It coordinates the authentication state and view transitions. +public struct AuthApp: View { + + // MARK: - Properties + + @State private var viewModel: AuthAppViewModel + @Environment(\.viewModelFactory) private var viewModelFactory + + // MARK: - Lifecycle + + init( + viewModel: AuthAppViewModel + ) { + self.viewModel = viewModel + } + + // MARK: - Public + + public var body: some View { + if viewModel.isLoggedIn { + LogoutView( + viewModel: viewModelFactory.makeLogoutViewModel() + ) + } else { + LoginView( + viewModel: viewModelFactory.makeLoginViewModel() + ) + } + } +} diff --git a/Packages/Auth/Sources/Auth/Views/App/AuthAppViewModel.swift b/Packages/Auth/Sources/Auth/Views/App/AuthAppViewModel.swift new file mode 100644 index 0000000..c8db94a --- /dev/null +++ b/Packages/Auth/Sources/Auth/Views/App/AuthAppViewModel.swift @@ -0,0 +1,39 @@ +import AuthSessionServiceInterface +import Observation +import SwiftUI + +@MainActor +@Observable +final class AuthAppViewModel { + + // MARK: - Properties + + private(set) var isLoggedIn = false + + private let authSessionService: any AuthSessionServiceProtocol + + @ObservationIgnored private var observationTask: Task? + + // MARK: - Lifecycle + + init( + authSessionService: any AuthSessionServiceProtocol + ) { + self.authSessionService = authSessionService + + observationTask = Task { @MainActor in + let currentState = await authSessionService.isLoggedIn + isLoggedIn = currentState + + for await loginState in authSessionService.observeLoginState() { + if isLoggedIn != loginState { + isLoggedIn = loginState + } + } + } + } + + deinit { + observationTask?.cancel() + } +} diff --git a/Packages/Auth/Sources/Auth/Views/Login/LoginView.swift b/Packages/Auth/Sources/Auth/Views/Login/LoginView.swift new file mode 100644 index 0000000..35a76ed --- /dev/null +++ b/Packages/Auth/Sources/Auth/Views/Login/LoginView.swift @@ -0,0 +1,55 @@ +import DesignSystem +import SwiftUI + +struct LoginView: View { + + // MARK: - Properties + + @State private var viewModel: LoginViewModel + @Environment(\.openURL) private var openURL + + // MARK: - Lifecycle + + init( + viewModel: LoginViewModel + ) { + self.viewModel = viewModel + } + + // MARK: - Public + + var body: some View { + VStack { + Image(systemName: "heart.circle.fill") + .font(.system(size: 56)) + .frame(width: 60, height: 60) + + Text("Hi there! Please log in to continue using the app.") + .font(.body) + + Button { + openURL(viewModel.codeRequestURL) + } label: { + Text("Log in") + } + } + .frame(.full) + .padding() + .onOpenURL { url in + viewModel.handleDeeplinkURL(url) + } + } +} + +// MARK: - Preview + +#if DEBUG + + #Preview { + LoginView( + viewModel: LoginViewModelMother.makeLoginViewModel() + ) + .frame(width: 300, height: 300) + } + +#endif diff --git a/Packages/Auth/Sources/Auth/Views/Login/LoginViewModel.swift b/Packages/Auth/Sources/Auth/Views/Login/LoginViewModel.swift new file mode 100644 index 0000000..f5cc1ba --- /dev/null +++ b/Packages/Auth/Sources/Auth/Views/Login/LoginViewModel.swift @@ -0,0 +1,38 @@ +import AuthRepository +import Foundation +import Observation +import OMGAPI + +@MainActor +@Observable +final class LoginViewModel { + + // MARK: - Properties + + private let repository: AuthRepositoryProtocol + + // MARK: - Computed Properties + + var codeRequestURL: URL { + AuthRequestFactory.makeOAuthCodeRequestURL()! + } + + // MARK: - Lifecycle + + init( + repository: AuthRepositoryProtocol + ) { + self.repository = repository + } + + // MARK: - Public + + func handleDeeplinkURL( + _ url: URL + ) { + Task { [weak self] in + guard let self else { return } + try await repository.handleDeepLinkURL(url) + } + } +} diff --git a/Packages/Auth/Sources/Auth/Views/Logout/LogoutView.swift b/Packages/Auth/Sources/Auth/Views/Logout/LogoutView.swift new file mode 100644 index 0000000..2d3b714 --- /dev/null +++ b/Packages/Auth/Sources/Auth/Views/Logout/LogoutView.swift @@ -0,0 +1,51 @@ +import DesignSystem +import SwiftUI + +struct LogoutView: View { + + // MARK: - Properties + + @State private var viewModel: LogoutViewModel + + // MARK: - Lifecycle + + init( + viewModel: LogoutViewModel + ) { + self.viewModel = viewModel + } + + // MARK: - Public + + var body: some View { + VStack { + Image(systemName: "heart.circle") + .font(.system(size: 56)) + .frame(width: 60, height: 60) + + Text("I'm sorry to see you go :-(") + .font(.body) + + Button { + viewModel.logout() + } label: { + Text("Log out") + } + } + .frame(.full) + .padding() + } +} + +// MARK: - Preview + +#if DEBUG + + #Preview { + LogoutView( + viewModel: LogoutViewModelMother.makeLogoutViewModel() + ) + .frame(width: 300, height: 300) + } + +#endif diff --git a/Packages/Auth/Sources/Auth/Views/Logout/LogoutViewModel.swift b/Packages/Auth/Sources/Auth/Views/Logout/LogoutViewModel.swift new file mode 100644 index 0000000..6740d67 --- /dev/null +++ b/Packages/Auth/Sources/Auth/Views/Logout/LogoutViewModel.swift @@ -0,0 +1,28 @@ +import AuthRepository +import Observation +import SwiftUI + +@MainActor +@Observable +final class LogoutViewModel { + + // MARK: - Properties + + private let repository: AuthRepositoryProtocol + + // MARK: - Lifecycle + + init( + repository: AuthRepositoryProtocol + ) { + self.repository = repository + } + + // MARK: - Public + + func logout() { + Task { + await repository.removeAccessToken() + } + } +} diff --git a/Packages/Auth/Sources/AuthNetworkService/AuthNetworkService.swift b/Packages/Auth/Sources/AuthNetworkService/AuthNetworkService.swift new file mode 100644 index 0000000..cf0ad2e --- /dev/null +++ b/Packages/Auth/Sources/AuthNetworkService/AuthNetworkService.swift @@ -0,0 +1,64 @@ +import MicroClient +import OMGAPI + +/// A protocol for network operations related to authentication token exchange. +/// +/// This protocol defines the interface for handling OAuth-style authentication flows, +/// specifically the exchange of authorization codes for access tokens. It manages the +/// network communication with the authentication server to complete the OAuth flow +/// after the user has been redirected back to the application with an authorization code. +/// +/// The protocol handles the critical step in OAuth flows where a temporary authorization +/// code (typically received via URL callback or deep link) is exchanged for a persistent +/// access token that can be used for authenticated API requests. +public protocol AuthNetworkServiceProtocol: AnyObject, Sendable { + + /// Exchanges an authorization code for an access token. + /// + /// This method implements the OAuth "authorization code" flow by sending the + /// authorization code to the authentication server and receiving an access token + /// in response. The authorization code is typically obtained when the user + /// completes the authentication process and is redirected back to the application. + /// + /// The access token returned by this method can be used for subsequent authenticated + /// API requests and should be securely stored for future use. + /// + /// This is a critical security operation that must be performed over HTTPS to + /// protect the authorization code and access token during transmission. + /// + /// - Parameter code: The authorization code received from the OAuth callback/redirect. + /// - Returns: The access token that can be used for authenticated requests. + /// - Throws: Network errors, authentication errors, or API validation errors if the code exchange fails. + func accessToken( + code: String + ) async throws -> String +} + +actor AuthNetworkService: AuthNetworkServiceProtocol { + + // MARK: - Properties + + private let networkClient: NetworkClientProtocol + + // MARK: - Lifecycle + + init( + networkClient: NetworkClientProtocol + ) { + self.networkClient = networkClient + } + + // MARK: - Public + + func accessToken( + code: String + ) async throws -> String { + let request = AuthRequestFactory.makeAuthRequest( + code: code + ) + + let response = try await networkClient.run(request) + + return response.value.accessToken + } +} diff --git a/Packages/Auth/Sources/AuthNetworkService/Factories/AuthNetworkServiceFactory.swift b/Packages/Auth/Sources/AuthNetworkService/Factories/AuthNetworkServiceFactory.swift new file mode 100644 index 0000000..66fa243 --- /dev/null +++ b/Packages/Auth/Sources/AuthNetworkService/Factories/AuthNetworkServiceFactory.swift @@ -0,0 +1,55 @@ +import MicroClient + +/// A protocol for creating authentication network service instances. +/// +/// This protocol defines the factory interface for creating properly configured +/// authentication network services with their required HTTP client dependencies. +/// The factory pattern abstracts the complex initialization of network services +/// and enables dependency injection of different network client implementations. +/// +/// Authentication network services handle OAuth-related HTTP requests including +/// authorization code exchange for access tokens. Implementations should configure +/// the service with appropriate network clients for API communication. +/// +/// ## Usage Example +/// ```swift +/// let factory: AuthNetworkServiceFactoryProtocol = AuthNetworkServiceFactory() +/// let service = factory.makeAuthNetworkService(networkClient: networkClient) +/// ``` +public protocol AuthNetworkServiceFactoryProtocol { + + /// Creates a new authentication network service instance. + /// + /// This method constructs a fully configured authentication network service + /// with the provided HTTP client. The service handles OAuth token exchange + /// requests and other authentication-related network operations. + /// + /// The created service provides: + /// - OAuth authorization code to access token exchange + /// - HTTP client abstraction for network operations + /// - Proper error handling for authentication failures + /// - Integration with the broader authentication system + /// + /// - Parameter networkClient: The network client for performing HTTP requests. + /// - Returns: A configured `AuthNetworkServiceProtocol` instance ready for use. + func makeAuthNetworkService( + networkClient: NetworkClientProtocol + ) -> AuthNetworkServiceProtocol +} + +public struct AuthNetworkServiceFactory: AuthNetworkServiceFactoryProtocol { + + // MARK: - Lifecycle + + public init() {} + + // MARK: - Public + + public func makeAuthNetworkService( + networkClient: NetworkClientProtocol + ) -> AuthNetworkServiceProtocol { + AuthNetworkService( + networkClient: networkClient + ) + } +} diff --git a/Packages/Auth/Sources/AuthPersistenceService/AuthPersistenceService.swift b/Packages/Auth/Sources/AuthPersistenceService/AuthPersistenceService.swift new file mode 100644 index 0000000..aae916e --- /dev/null +++ b/Packages/Auth/Sources/AuthPersistenceService/AuthPersistenceService.swift @@ -0,0 +1,82 @@ +import AuthSessionServiceInterface +import Foundation + +/// A protocol for managing secure persistence of authentication tokens. +/// +/// This protocol defines the interface for secure storage and retrieval of access tokens +/// using the device's secure storage mechanisms. It acts as an abstraction layer over +/// the AuthSessionService, providing a clean interface for authentication-related +/// persistence operations. +/// +/// The service ensures that access tokens are stored securely using the device's Keychain, +/// providing protection against unauthorized access and ensuring tokens persist across +/// app launches and system reboots. All token operations are performed asynchronously +/// to maintain responsive UI performance. +/// +/// This persistence layer integrates with the broader authentication system to provide +/// reactive token state management and automatic cleanup during logout operations. +public protocol AuthPersistenceServiceProtocol: AnyObject, Sendable { + + /// Retrieves the currently stored access token. + /// + /// This method fetches the access token that was previously stored in secure storage. + /// The token is retrieved from the device's Keychain through the authentication + /// session service, ensuring secure access to sensitive authentication data. + /// + /// - Returns: The stored access token, or `nil` if no token is available. + func fetchAccessToken() async -> String? + + /// Securely stores an access token. + /// + /// This method saves the provided access token to secure storage using the device's + /// Keychain. The token will persist across app launches and be available for + /// authenticated API requests until explicitly removed or replaced. + /// + /// Storing a token automatically updates the authentication state, triggering + /// notifications to components that observe authentication state changes. + /// + /// - Parameter value: The access token to store securely. + func storeAccessToken(value: String) async + + /// Removes the stored access token and logs out the user. + /// + /// This method securely removes the access token from storage, effectively + /// logging out the user. The token is permanently deleted from the device's + /// Keychain and cannot be recovered. + /// + /// Removing a token automatically updates the authentication state to unauthenticated + /// and triggers logout events for components that observe authentication changes, + /// enabling automatic cleanup of user data and UI state. + func removeAccessToken() async +} + +actor AuthPersistenceService: AuthPersistenceServiceProtocol { + + // MARK: - Properties + + private let authSessionService: any AuthSessionServiceProtocol + + // MARK: - Lifecycle + + init( + authSessionService: any AuthSessionServiceProtocol + ) { + self.authSessionService = authSessionService + } + + // MARK: - Public + + func fetchAccessToken() async -> String? { + await authSessionService.accessToken + } + + func storeAccessToken( + value: String + ) async { + await authSessionService.setAccessToken(value) + } + + func removeAccessToken() async { + await authSessionService.setAccessToken(nil) + } +} diff --git a/Packages/Auth/Sources/AuthPersistenceService/Factories/AuthPersistenceServiceFactory.swift b/Packages/Auth/Sources/AuthPersistenceService/Factories/AuthPersistenceServiceFactory.swift new file mode 100644 index 0000000..7738031 --- /dev/null +++ b/Packages/Auth/Sources/AuthPersistenceService/Factories/AuthPersistenceServiceFactory.swift @@ -0,0 +1,55 @@ +import AuthSessionServiceInterface + +/// A protocol for creating authentication persistence service instances. +/// +/// This protocol defines the factory interface for creating properly configured +/// authentication persistence services with their required session service dependencies. +/// The factory pattern abstracts the complex initialization of persistence services +/// and enables dependency injection of different session service implementations. +/// +/// Authentication persistence services handle secure storage and retrieval of OAuth +/// tokens and authentication state using the device's Keychain. Implementations +/// should configure the service with appropriate session services for state management. +/// +/// ## Usage Example +/// ```swift +/// let factory: AuthPersistenceServiceFactoryProtocol = AuthPersistenceServiceFactory() +/// let service = factory.makeAuthPersistenceService(authSessionService: sessionService) +/// ``` +public protocol AuthPersistenceServiceFactoryProtocol { + + /// Creates a new authentication persistence service instance. + /// + /// This method constructs a fully configured authentication persistence service + /// with the provided session service. The persistence service handles secure + /// storage of OAuth tokens and authentication state in the device's Keychain. + /// + /// The created service provides: + /// - Secure token storage using Keychain Services + /// - OAuth token lifecycle management (store, retrieve, remove) + /// - Integration with session state management + /// - Proper error handling for storage operations + /// + /// - Parameter authSessionService: The session service for managing authentication state. + /// - Returns: A configured `AuthPersistenceServiceProtocol` instance ready for use. + func makeAuthPersistenceService( + authSessionService: any AuthSessionServiceProtocol + ) -> AuthPersistenceServiceProtocol +} + +public struct AuthPersistenceServiceFactory: AuthPersistenceServiceFactoryProtocol { + + // MARK: - Lifecycle + + public init() {} + + // MARK: - Public + + public func makeAuthPersistenceService( + authSessionService: any AuthSessionServiceProtocol + ) -> AuthPersistenceServiceProtocol { + AuthPersistenceService( + authSessionService: authSessionService + ) + } +} diff --git a/Packages/Auth/Sources/AuthRepository/AuthRepository.swift b/Packages/Auth/Sources/AuthRepository/AuthRepository.swift new file mode 100644 index 0000000..1602a31 --- /dev/null +++ b/Packages/Auth/Sources/AuthRepository/AuthRepository.swift @@ -0,0 +1,153 @@ +import AuthNetworkService +import AuthPersistenceService +import Foundation + +/// Errors that can occur during authentication repository operations. +/// +/// This enumeration defines the specific error cases that can arise when +/// working with authentication tokens and repository operations. +public enum AuthRepositoryError: Error { + + /// Indicates that no access token is available when one is required. + /// + /// This error is thrown when attempting to retrieve an access token + /// but none has been stored, typically indicating the user is not + /// authenticated or the token has been removed/expired. + case missingToken +} + +/// A repository protocol for managing authentication flow and token storage. +/// +/// This protocol defines the interface for coordinating the complete authentication +/// process, from handling OAuth callbacks through secure token storage. It manages +/// the integration between network-based authentication operations and secure local +/// token persistence. +/// +/// The repository handles the OAuth "authorization code" flow where users are redirected +/// to an external authentication provider, then redirected back to the application with +/// an authorization code that must be exchanged for an access token. It also provides +/// secure storage and retrieval of access tokens using the device's Keychain. +/// +/// The repository follows the repository pattern, abstracting the complexities of +/// OAuth flows and secure storage to provide a clean interface for authentication +/// management throughout the application. +public protocol AuthRepositoryProtocol: Sendable { + + /// Handles OAuth callback URLs containing authorization codes. + /// + /// This method processes deep link URLs that result from OAuth authentication flows. + /// When users complete authentication with the external provider, they are redirected + /// back to the application with a URL containing an authorization code. This method: + /// + /// 1. Parses the URL to extract the authorization code + /// 2. Validates that the URL is an authentication callback (host == "authenticate") + /// 3. Exchanges the code for an access token via the network service + /// 4. Securely stores the access token using the persistence service + /// + /// The method silently returns if the URL is not a valid authentication callback, + /// allowing other URL handlers to process non-authentication deep links. + /// + /// - Parameter url: The deep link URL containing the OAuth authorization code. + /// - Throws: Network errors from the token exchange or validation errors if the flow fails. + func handleDeepLinkURL( + _ url: URL + ) async throws + + /// Stores an access token securely. + /// + /// This method provides a direct way to store an access token, typically used + /// in scenarios where a token is obtained through means other than the standard + /// OAuth deep link flow (such as manual token entry or alternative authentication methods). + /// + /// The token is stored securely using the same mechanism as tokens obtained + /// through the OAuth flow, ensuring consistent security practices. + /// + /// - Parameter accessToken: The access token to store securely. + func storeToken( + accessToken: String + ) async + + /// Retrieves the currently stored access token. + /// + /// This method returns the access token that was previously stored through + /// authentication flows. The token can be used for authenticated API requests. + /// + /// - Returns: The stored access token. + /// - Throws: `AuthRepositoryError.missingToken` if no token is available. + func accessToken() async throws -> String + + /// Removes the stored access token and logs out the user. + /// + /// This method securely removes the access token from storage, effectively + /// logging out the user. After calling this method, subsequent calls to + /// `accessToken()` will throw `AuthRepositoryError.missingToken` until + /// the user authenticates again. + /// + /// This operation also triggers logout events for components that observe + /// authentication state changes. + func removeAccessToken() async +} + +actor AuthRepository: AuthRepositoryProtocol { + + // MARK: - Properties + + private let networkService: AuthNetworkServiceProtocol + private let persistenceService: AuthPersistenceServiceProtocol + + // MARK: - Lifecycle + + init( + networkService: AuthNetworkServiceProtocol, + persistenceService: AuthPersistenceServiceProtocol + ) { + self.networkService = networkService + self.persistenceService = persistenceService + } + + // MARK: - Public + + func handleDeepLinkURL( + _ url: URL + ) async throws { + let components = URLComponents( + url: url, + resolvingAgainstBaseURL: true + ) + + let host = components?.host + let code = components?.queryItems? + .first { $0.name == "code" }? + .value + + guard host == "authenticate", let code else { return } + + let accessToken = try await networkService.accessToken( + code: code + ) + + await persistenceService.storeAccessToken( + value: accessToken + ) + } + + func storeToken( + accessToken: String + ) async { + await persistenceService.storeAccessToken( + value: accessToken + ) + } + + func accessToken() async throws -> String { + guard let accessToken = await persistenceService.fetchAccessToken() else { + throw AuthRepositoryError.missingToken + } + + return accessToken + } + + func removeAccessToken() async { + await persistenceService.removeAccessToken() + } +} diff --git a/Packages/Auth/Sources/AuthRepository/Factories/AuthRepositoryFactory.swift b/Packages/Auth/Sources/AuthRepository/Factories/AuthRepositoryFactory.swift new file mode 100644 index 0000000..37b3ece --- /dev/null +++ b/Packages/Auth/Sources/AuthRepository/Factories/AuthRepositoryFactory.swift @@ -0,0 +1,55 @@ +import AuthNetworkService +import AuthPersistenceService + +/// A protocol for creating authentication repository instances. +/// +/// This protocol defines the factory interface for creating properly configured +/// authentication repositories with their required dependencies. The factory pattern +/// abstracts the complex initialization of authentication repositories and their +/// network and persistence service dependencies. +/// +/// Implementations should configure the repository with appropriate network services +/// for OAuth token exchange and persistence services for secure token storage. +public protocol AuthRepositoryFactoryProtocol { + + /// Creates a new authentication repository instance. + /// + /// This method constructs a fully configured authentication repository with + /// the provided network and persistence services. The repository coordinates + /// between these services to provide complete OAuth authentication flow + /// management and secure token storage. + /// + /// The created repository handles: + /// - OAuth authorization code exchange via the network service + /// - Secure token storage and retrieval via the persistence service + /// - Deep link URL parsing for authentication callbacks + /// - Token lifecycle management (store, retrieve, remove) + /// + /// - Parameters: + /// - networkService: The network service for OAuth token exchange operations. + /// - persistenceService: The persistence service for secure token storage. + /// - Returns: A configured `AuthRepositoryProtocol` instance ready for use. + func makeAuthRepository( + networkService: AuthNetworkServiceProtocol, + persistenceService: AuthPersistenceServiceProtocol + ) -> AuthRepositoryProtocol +} + +public struct AuthRepositoryFactory: AuthRepositoryFactoryProtocol { + + // MARK: - Lifecycle + + public init() {} + + // MARK: - Public + + public func makeAuthRepository( + networkService: AuthNetworkServiceProtocol, + persistenceService: AuthPersistenceServiceProtocol + ) -> AuthRepositoryProtocol { + AuthRepository( + networkService: networkService, + persistenceService: persistenceService + ) + } +} diff --git a/Packages/Auth/Tests/AuthNetworkServiceTests/AuthNetworkServiceTests.swift b/Packages/Auth/Tests/AuthNetworkServiceTests/AuthNetworkServiceTests.swift new file mode 100644 index 0000000..3aaa712 --- /dev/null +++ b/Packages/Auth/Tests/AuthNetworkServiceTests/AuthNetworkServiceTests.swift @@ -0,0 +1,4 @@ +import XCTest +@testable import AuthNetworkService + +final class AuthNetworkServiceTests: XCTestCase {} diff --git a/Packages/Auth/Tests/AuthPersistenceServiceTests/AuthPersistenceServiceTests.swift b/Packages/Auth/Tests/AuthPersistenceServiceTests/AuthPersistenceServiceTests.swift new file mode 100644 index 0000000..0a10382 --- /dev/null +++ b/Packages/Auth/Tests/AuthPersistenceServiceTests/AuthPersistenceServiceTests.swift @@ -0,0 +1,4 @@ +import XCTest +@testable import AuthPersistenceService + +final class AuthPersistenceServiceTests: XCTestCase {} diff --git a/Packages/Auth/Tests/AuthRepositoryTests/AuthRepositoryTests.swift b/Packages/Auth/Tests/AuthRepositoryTests/AuthRepositoryTests.swift new file mode 100644 index 0000000..1d0b0e7 --- /dev/null +++ b/Packages/Auth/Tests/AuthRepositoryTests/AuthRepositoryTests.swift @@ -0,0 +1,4 @@ +import XCTest +@testable import AuthRepository + +final class AuthRepositoryTests: XCTestCase {} diff --git a/Packages/AuthSession/.gitignore b/Packages/AuthSession/.gitignore new file mode 100644 index 0000000..3b29812 --- /dev/null +++ b/Packages/AuthSession/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Packages/AuthSession/Package.swift b/Packages/AuthSession/Package.swift new file mode 100644 index 0000000..2eb5502 --- /dev/null +++ b/Packages/AuthSession/Package.swift @@ -0,0 +1,36 @@ +// swift-tools-version: 6.2 + +import PackageDescription + +let package = Package( + name: "AuthSession", + platforms: [ + .macOS(.v15), + .iOS(.v18) + ], + products: [ + .library( + name: "AuthSessionService", + targets: ["AuthSessionService"] + ), + .library( + name: "AuthSessionServiceInterface", + targets: ["AuthSessionServiceInterface"] + ) + ], + dependencies: [], + targets: [ + .target( + name: "AuthSessionServiceInterface", + dependencies: [] + ), + .target( + name: "AuthSessionService", + dependencies: ["AuthSessionServiceInterface"] + ), + .testTarget( + name: "AuthSessionServiceTests", + dependencies: ["AuthSessionService"] + ) + ] +) diff --git a/Packages/AuthSession/Sources/AuthSessionService/AuthSessionService.swift b/Packages/AuthSession/Sources/AuthSessionService/AuthSessionService.swift new file mode 100644 index 0000000..3702a07 --- /dev/null +++ b/Packages/AuthSession/Sources/AuthSessionService/AuthSessionService.swift @@ -0,0 +1,123 @@ +import AuthSessionServiceInterface +import Foundation + +public actor AuthSessionService: AuthSessionServiceProtocol { + + // MARK: - Properties + + private var _accessToken: String? + private var _isLoggedIn: Bool + private let keychainStore: KeychainStoreProtocol + + private let loginStateContinuation: AsyncStream.Continuation + private let logoutEventContinuation: AsyncStream.Continuation + private var loginStateObservers: [AsyncStream.Continuation] = [] + private var logoutEventObservers: [AsyncStream.Continuation] = [] + public nonisolated let logoutEventStream: AsyncStream + + public var isLoggedIn: Bool { + _isLoggedIn + } + + public var accessToken: String? { + _accessToken + } + + // MARK: - Lifecycle + + init( + keychainStore: KeychainStoreProtocol + ) { + self.keychainStore = keychainStore + + let token = keychainStore.wrappedValue + _accessToken = token + _isLoggedIn = token != nil + + var loginContinuation: AsyncStream.Continuation! + _ = AsyncStream { continuation in + loginContinuation = continuation + } + loginStateContinuation = loginContinuation + + var logoutContinuation: AsyncStream.Continuation! + var logoutStream: AsyncStream! + logoutStream = AsyncStream { continuation in + logoutContinuation = continuation + } + logoutEventStream = logoutStream + logoutEventContinuation = logoutContinuation + } + + // MARK: - Public + + public func setAccessToken(_ token: String?) async { + let previousToken = _accessToken + _accessToken = token + keychainStore.wrappedValue = token + + let newLoginState = token != nil + + if _isLoggedIn != newLoginState { + _isLoggedIn = newLoginState + for observer in loginStateObservers { + observer.yield(newLoginState) + } + } + + if previousToken != nil, token == nil { + for observer in logoutEventObservers { + observer.yield() + } + } + } + + public nonisolated func observeLoginState() -> AsyncStream { + AsyncStream { continuation in + Task { + let currentState = await self.isLoggedIn + continuation.yield(currentState) + + await self.addLoginStateObserver(continuation) + + continuation.onTermination = { _ in + Task { + await self.removeLoginStateObserver(continuation) + } + } + } + } + } + + public nonisolated func observeLogoutEvents() -> AsyncStream { + AsyncStream { continuation in + Task { + await self.addLogoutEventObserver(continuation) + + continuation.onTermination = { _ in + Task { + await self.removeLogoutEventObserver(continuation) + } + } + } + } + } + + private func addLoginStateObserver(_ continuation: AsyncStream.Continuation) { + loginStateObservers.append(continuation) + } + + private func removeLoginStateObserver(_ continuation: AsyncStream.Continuation) { + // Note: AsyncStream.Continuation cleanup is handled by the onTermination callback + // A more robust solution might involve wrapping continuations in identifiable wrappers + } + + private func addLogoutEventObserver(_ continuation: AsyncStream.Continuation) { + logoutEventObservers.append(continuation) + } + + private func removeLogoutEventObserver(_ continuation: AsyncStream.Continuation) { + // Note: AsyncStream.Continuation cleanup is handled by the onTermination callback + // A more robust solution might involve wrapping continuations in identifiable wrappers + } +} diff --git a/Packages/AuthSession/Sources/AuthSessionService/Factories/AuthSessionServiceFactory.swift b/Packages/AuthSession/Sources/AuthSessionService/Factories/AuthSessionServiceFactory.swift new file mode 100644 index 0000000..0292b44 --- /dev/null +++ b/Packages/AuthSession/Sources/AuthSessionService/Factories/AuthSessionServiceFactory.swift @@ -0,0 +1,48 @@ +import AuthSessionServiceInterface +import Foundation + +/// A protocol for creating authentication session service instances. +/// +/// This protocol defines the factory interface for creating properly configured +/// authentication session services with secure Keychain storage. The factory pattern +/// abstracts the complex initialization of the authentication service and its dependencies, +/// providing a clean interface for dependency injection and testing. +/// +/// Implementations should configure the service with appropriate secure storage +/// mechanisms and any required dependencies for authentication state management. +public protocol AuthSessionServiceFactoryProtocol { + + /// Creates a new authentication session service instance. + /// + /// This method constructs a fully configured authentication session service + /// with secure Keychain storage for access tokens. The service is initialized + /// with any existing tokens from secure storage and is ready for immediate use. + /// + /// The created service handles: + /// - Secure token storage and retrieval via Keychain + /// - Authentication state management + /// - Reactive streams for login state and logout events + /// - Automatic token persistence across app launches + /// + /// - Returns: A configured `AuthSessionServiceProtocol` instance ready for use. + func makeAuthSessionService() -> any AuthSessionServiceProtocol +} + +public struct AuthSessionServiceFactory: AuthSessionServiceFactoryProtocol { + + // MARK: - Lifecycle + + public init() {} + + // MARK: - Public + + public func makeAuthSessionService() -> any AuthSessionServiceProtocol { + let store = KeychainStore( + "Triton: Access Token" + ) + + return AuthSessionService( + keychainStore: store + ) + } +} diff --git a/Packages/AuthSession/Sources/AuthSessionService/Fixtures/KeychainStoreMother.swift b/Packages/AuthSession/Sources/AuthSessionService/Fixtures/KeychainStoreMother.swift new file mode 100644 index 0000000..1df5bc4 --- /dev/null +++ b/Packages/AuthSession/Sources/AuthSessionService/Fixtures/KeychainStoreMother.swift @@ -0,0 +1,19 @@ +#if DEBUG + + enum KeychainStoreMother { + + // MARK: - Nested types + + private final class FakeKeychainStore: KeychainStoreProtocol, @unchecked Sendable { + + var wrappedValue: String? + } + + // MARK: - Public + + static func makeKeychainStore() -> KeychainStoreProtocol { + FakeKeychainStore() + } + } + +#endif diff --git a/Packages/AuthSession/Sources/AuthSessionService/Stores/KeychainStore.swift b/Packages/AuthSession/Sources/AuthSessionService/Stores/KeychainStore.swift new file mode 100644 index 0000000..7cf7ef5 --- /dev/null +++ b/Packages/AuthSession/Sources/AuthSessionService/Stores/KeychainStore.swift @@ -0,0 +1,129 @@ +import Foundation + +protocol KeychainStoreProtocol: AnyObject, Sendable { + + /// Value stored in the Keychain. + var wrappedValue: String? { get set } +} + +final class KeychainStore: KeychainStoreProtocol, @unchecked Sendable { + + // MARK: - Nested types + + typealias ItemDeleter = ( + CFDictionary + ) -> OSStatus + + typealias ItemAdder = ( + CFDictionary, + UnsafeMutablePointer? + ) -> OSStatus + + typealias ItemCopyMatcher = ( + CFDictionary, + UnsafeMutablePointer? + ) -> OSStatus + + // MARK: - Properties + + var wrappedValue: String? { + get { + guard let data = load(key: key) else { + return nil + } + + return String(data: data, encoding: .utf8) + } + + set { + guard + let value = newValue, + let data = value.data(using: .utf8) + else { + delete(key) + return + } + + save(key: key, data: data) + } + } + + private let key: String + private let itemDeleter: ItemDeleter + private let itemAdder: ItemAdder + private let itemCopyMatcher: ItemCopyMatcher + + // MARK: - Lifecycle + + init( + _ key: String, + itemDeleter: @escaping ItemDeleter = SecItemDelete, + itemAdder: @escaping ItemAdder = SecItemAdd, + itemCopyMatcher: @escaping ItemCopyMatcher = SecItemCopyMatching + ) { + self.key = key + self.itemDeleter = itemDeleter + self.itemAdder = itemAdder + self.itemCopyMatcher = itemCopyMatcher + } + + // MARK: - Private + + @discardableResult + private func save( + key: String, + data: Data + ) -> OSStatus { + let query = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecValueData as String: data + ] as [String: Any] + + _ = itemDeleter( + query as CFDictionary + ) + + return itemAdder( + query as CFDictionary, + nil + ) + } + + private func load( + key: String + ) -> Data? { + let query = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecReturnData as String: kCFBooleanTrue!, + kSecMatchLimit as String: kSecMatchLimitOne + ] as [String: Any] + + var data: AnyObject? + let status = itemCopyMatcher( + query as CFDictionary, + &data + ) + + if status == noErr { + return data as? Data + } else { + return nil + } + } + + @discardableResult + private func delete( + _ key: String + ) -> OSStatus { + let query = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key + ] as [String: Any] + + return itemDeleter( + query as CFDictionary + ) + } +} diff --git a/Packages/AuthSession/Sources/AuthSessionServiceInterface/AuthSessionServiceProtocol.swift b/Packages/AuthSession/Sources/AuthSessionServiceInterface/AuthSessionServiceProtocol.swift new file mode 100644 index 0000000..fe1ff82 --- /dev/null +++ b/Packages/AuthSession/Sources/AuthSessionServiceInterface/AuthSessionServiceProtocol.swift @@ -0,0 +1,76 @@ +import Foundation + +/// A protocol for managing authentication session state and secure token storage. +/// +/// This protocol defines the interface for handling user authentication sessions, +/// including secure access token management and reactive authentication state monitoring. +/// It provides a centralized way to manage authentication state across the application +/// with secure Keychain storage and reactive streams for state changes. +/// +/// The service handles the complete authentication lifecycle, from initial login through +/// logout, with automatic cleanup of sensitive data and notification of authentication +/// state changes to dependent components. +/// +/// All operations are async-safe and designed to work seamlessly with SwiftUI and +/// other reactive components that need to respond to authentication state changes. +public protocol AuthSessionServiceProtocol: Sendable { + + /// The current access token used for authenticated network requests. + /// + /// This token is securely stored in the device's Keychain and is used to authenticate + /// API requests. The token is automatically loaded from secure storage on app startup + /// and persisted across app launches. Returns `nil` when the user is not authenticated. + /// + /// The token should be included in network request headers for authenticated endpoints. + var accessToken: String? { get async } + + /// The current authentication state of the user. + /// + /// This computed property returns `true` when a valid access token is available, + /// indicating the user is authenticated and can make authenticated requests. + /// Returns `false` when no token is available or the user has logged out. + /// + /// This property is reactive and its changes trigger updates to observers + /// via the `observeLoginState()` stream. + var isLoggedIn: Bool { get async } + + /// Updates the access token and authentication state. + /// + /// This method securely stores the provided token in the Keychain and updates + /// the authentication state accordingly. When a token is provided, the user + /// becomes authenticated. When `nil` is provided, the user is logged out and + /// the token is removed from secure storage. + /// + /// Setting the token automatically triggers notifications to observers: + /// - Login state observers receive the new authentication state + /// - Logout event observers are notified when transitioning from authenticated to unauthenticated + /// + /// - Parameter token: The access token to store, or `nil` to log out and clear the token. + func setAccessToken(_ token: String?) async + + /// Provides a stream of authentication state changes. + /// + /// This method returns an AsyncStream that emits the current authentication state + /// immediately upon subscription, followed by updates whenever the authentication + /// state changes (login or logout events). + /// + /// The stream is useful for reactive UI components that need to update based on + /// authentication state, such as showing/hiding login screens or authenticated content. + /// + /// - Returns: An `AsyncStream` that emits `true` for authenticated state and `false` for unauthenticated + /// state. + func observeLoginState() -> AsyncStream + + /// Provides a stream of logout events. + /// + /// This method returns an AsyncStream that emits events specifically when the user + /// logs out (transitions from authenticated to unauthenticated state). This is useful + /// for components that need to perform cleanup or reset operations when the user logs out, + /// such as clearing cached data, resetting UI state, or clearing local databases. + /// + /// The stream only emits when transitioning from having a token to not having a token. + /// It does not emit during initial app startup if the user is already logged out. + /// + /// - Returns: An `AsyncStream` that emits events when the user logs out. + func observeLogoutEvents() -> AsyncStream +} diff --git a/Packages/AuthSession/Sources/AuthSessionServiceInterface/Fixtures/AuthSessionServiceMother.swift b/Packages/AuthSession/Sources/AuthSessionServiceInterface/Fixtures/AuthSessionServiceMother.swift new file mode 100644 index 0000000..0cb1c00 --- /dev/null +++ b/Packages/AuthSession/Sources/AuthSessionServiceInterface/Fixtures/AuthSessionServiceMother.swift @@ -0,0 +1,69 @@ +#if DEBUG + + import Foundation + + public enum AuthSessionServiceMother { + + // MARK: - Nested types + + private actor FakeAuthSessionService: AuthSessionServiceProtocol { + + // MARK: - Properties + + private var _accessToken: String? + private var _isLoggedIn: Bool + + var accessToken: String? { + _accessToken + } + + var isLoggedIn: Bool { + _isLoggedIn + } + + // MARK: - Lifecycle + + init( + accessToken: String? = nil, + isLoggedIn: Bool + ) { + _accessToken = accessToken + _isLoggedIn = isLoggedIn + } + + // MARK: - Public + + func setAccessToken(_ token: String?) async { + _accessToken = token + _isLoggedIn = token != nil + } + + nonisolated func observeLoginState() -> AsyncStream { + AsyncStream { continuation in + Task { + await continuation.yield(self.isLoggedIn) + continuation.finish() + } + } + } + + nonisolated func observeLogoutEvents() -> AsyncStream { + AsyncStream { continuation in + continuation.finish() + } + } + } + + // MARK: - Public + + public static func makeAuthSessionService( + loggedIn: Bool = false + ) -> any AuthSessionServiceProtocol { + FakeAuthSessionService( + accessToken: loggedIn ? "test-token" : nil, + isLoggedIn: loggedIn + ) + } + } + +#endif diff --git a/Packages/AuthSession/Tests/AuthSessionServiceTests/AuthSessionServiceTests.swift b/Packages/AuthSession/Tests/AuthSessionServiceTests/AuthSessionServiceTests.swift new file mode 100644 index 0000000..c841d1d --- /dev/null +++ b/Packages/AuthSession/Tests/AuthSessionServiceTests/AuthSessionServiceTests.swift @@ -0,0 +1,106 @@ +import AuthSessionServiceInterface +import XCTest +@testable import AuthSessionService + +final class AuthSessionServiceTests: XCTestCase { + + // MARK: - Properties + + private var service: (any AuthSessionServiceProtocol)! + private var fakeKeychainStore: KeychainStoreProtocol! + + // MARK: - Lifecycle + + override func setUp() async throws { + try await super.setUp() + + let store = KeychainStoreMother.makeKeychainStore() + fakeKeychainStore = store + + service = AuthSessionService( + keychainStore: store + ) + } + + override func tearDown() async throws { + service = nil + fakeKeychainStore = nil + + try await super.tearDown() + } + + // MARK: - Tests + + func testAccessTokenSignIn() async { + // Given + let initialState = await service.isLoggedIn + XCTAssertFalse(initialState, "Should start as logged out") + + // When + await service.setAccessToken("1b302f5c-157a-4caf-b450-8e1f7cde01ab") + + // Then + let finalState = await service.isLoggedIn + + XCTAssertTrue( + finalState, + "It should be logged in after setting token" + ) + + XCTAssertEqual( + fakeKeychainStore.wrappedValue, + "1b302f5c-157a-4caf-b450-8e1f7cde01ab", + "It should store the correct access token" + ) + } + + func testAccessTokenSignOut() async { + // Given + await service.setAccessToken("1b302f5c-157a-4caf-b450-8e1f7cde01ab") + let loggedInState = await service.isLoggedIn + + XCTAssertTrue( + loggedInState, + "It should be logged in initially" + ) + + // When + await service.setAccessToken(nil) + + // Then + let loggedOutState = await service.isLoggedIn + + XCTAssertFalse( + loggedOutState, + "it should be logged out after clearing token" + ) + + XCTAssertNil( + fakeKeychainStore.wrappedValue, + "It should clear out the access token" + ) + } + + func testObserveLoginStateYieldsCurrentState() async { + // Given + let initialState = await service.isLoggedIn + + XCTAssertFalse( + initialState, + "it should start logged out" + ) + + // When + let stream = service.observeLoginState() + + // Then + var iterator = stream.makeAsyncIterator() + let firstValue = await iterator.next() + + XCTAssertEqual( + firstValue, + false, + "It should yielded the first value correctly" + ) + } +} diff --git a/Packages/DesignSystem/.gitignore b/Packages/DesignSystem/.gitignore new file mode 100644 index 0000000..3b29812 --- /dev/null +++ b/Packages/DesignSystem/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Packages/DesignSystem/Package.swift b/Packages/DesignSystem/Package.swift new file mode 100644 index 0000000..9bee535 --- /dev/null +++ b/Packages/DesignSystem/Package.swift @@ -0,0 +1,32 @@ +// swift-tools-version: 6.2 + +import PackageDescription + +let package = Package( + name: "DesignSystem", + platforms: [ + .macOS(.v15), + .iOS(.v18) + ], + products: [ + .library( + name: "DesignSystem", + targets: ["DesignSystem"] + ) + ], + dependencies: [ + .package( + name: "FoundationExtensions", + path: "../FoundationExtensions" + ) + ], + targets: [ + .target( + name: "DesignSystem", + dependencies: ["FoundationExtensions"], + resources: [ + .copy("Views/EmojiPicker/Data/Emojis.json") + ] + ) + ] +) diff --git a/Packages/DesignSystem/Sources/DesignSystem/Colors/Color+Definitions.swift b/Packages/DesignSystem/Sources/DesignSystem/Colors/Color+Definitions.swift new file mode 100644 index 0000000..847cd69 --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Colors/Color+Definitions.swift @@ -0,0 +1,24 @@ +import SwiftUI + +public extension Color { + + static let omgAccountBackground = Color("accountBackground", bundle: .module) + static let omgBackground = Color("background", bundle: .module) + static let omgButtonBackground = Color("button", bundle: .module) + static let omg0 = Color("Color 0", bundle: .module) + static let omg1 = Color("Color 1", bundle: .module) + static let omg2 = Color("Color 2", bundle: .module) + static let omg3 = Color("Color 3", bundle: .module) + static let omg4 = Color("Color 4", bundle: .module) + static let omg5 = Color("Color 5", bundle: .module) + static let omg6 = Color("Color 6", bundle: .module) + static let omg7 = Color("Color 7", bundle: .module) + static let omg8 = Color("Color 8", bundle: .module) + static let omg9 = Color("Color 9", bundle: .module) + static let omg10 = Color("Color 10", bundle: .module) + static let omg11 = Color("Color 11", bundle: .module) + static let omg12 = Color("Color 12", bundle: .module) + static let omg13 = Color("Color 13", bundle: .module) + static let omg14 = Color("Color 14", bundle: .module) + static let omg15 = Color("Color 15", bundle: .module) +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Colors/Color+Palette.swift b/Packages/DesignSystem/Sources/DesignSystem/Colors/Color+Palette.swift new file mode 100644 index 0000000..ed644f5 --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Colors/Color+Palette.swift @@ -0,0 +1,32 @@ +import SwiftUI + +public extension Color { + + static let allColors: [Color] = [ + omg0, + omg1, + omg2, + omg3, + omg4, + omg5, + omg6, + omg7, + omg8, + omg9, + omg10, + omg11, + omg12, + omg13, + omg14, + omg15 + ] + + static func color(at position: Int) -> Color { + let modPosition = position % allColors.count + let index = modPosition < 0 + ? modPosition + allColors.count + : modPosition + + return allColors[index] + } +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 0.colorset/Contents.json b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 0.colorset/Contents.json new file mode 100644 index 0000000..7503be3 --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 0.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xDB", + "red" : "0xDB" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 1.colorset/Contents.json b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 1.colorset/Contents.json new file mode 100644 index 0000000..e03c710 --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 1.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xE7", + "red" : "0xDB" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 10.colorset/Contents.json b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 10.colorset/Contents.json new file mode 100644 index 0000000..0d247d9 --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 10.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xDB", + "green" : "0xFF", + "red" : "0xF9" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 11.colorset/Contents.json b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 11.colorset/Contents.json new file mode 100644 index 0000000..fee7c85 --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 11.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xDB", + "green" : "0xDB", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 12.colorset/Contents.json b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 12.colorset/Contents.json new file mode 100644 index 0000000..04e956f --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 12.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE4", + "green" : "0xDB", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 13.colorset/Contents.json b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 13.colorset/Contents.json new file mode 100644 index 0000000..1844b65 --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 13.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF0", + "green" : "0xDB", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 14.colorset/Contents.json b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 14.colorset/Contents.json new file mode 100644 index 0000000..6c85f49 --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 14.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xDB", + "green" : "0xE1", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 15.colorset/Contents.json b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 15.colorset/Contents.json new file mode 100644 index 0000000..ccf8af4 --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 15.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xDB", + "green" : "0xEA", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 2.colorset/Contents.json b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 2.colorset/Contents.json new file mode 100644 index 0000000..3cf65ab --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 2.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xF9", + "red" : "0xDB" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 3.colorset/Contents.json b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 3.colorset/Contents.json new file mode 100644 index 0000000..cab61c8 --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 3.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE7", + "green" : "0xF6", + "red" : "0xE4" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 4.colorset/Contents.json b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 4.colorset/Contents.json new file mode 100644 index 0000000..6c3899c --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 4.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF0", + "green" : "0xF6", + "red" : "0xE4" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 5.colorset/Contents.json b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 5.colorset/Contents.json new file mode 100644 index 0000000..c12aa82 --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 5.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF3", + "green" : "0xEB", + "red" : "0xE7" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 6.colorset/Contents.json b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 6.colorset/Contents.json new file mode 100644 index 0000000..d60e86c --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 6.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE9", + "green" : "0xF0", + "red" : "0xEB" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 7.colorset/Contents.json b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 7.colorset/Contents.json new file mode 100644 index 0000000..e32a92f --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 7.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF3", + "green" : "0xE7", + "red" : "0xEF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 8.colorset/Contents.json b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 8.colorset/Contents.json new file mode 100644 index 0000000..b454c33 --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 8.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE7", + "green" : "0xE7", + "red" : "0xF3" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 9.colorset/Contents.json b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 9.colorset/Contents.json new file mode 100644 index 0000000..d1c3ff2 --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Color 9.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE2", + "green" : "0xE9", + "red" : "0xF7" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Contents.json b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/accountBackground.colorset/Contents.json b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/accountBackground.colorset/Contents.json new file mode 100644 index 0000000..2ce2423 --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/accountBackground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.909", + "green" : "0.847", + "red" : "0.4" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/background.colorset/Contents.json b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/background.colorset/Contents.json new file mode 100644 index 0000000..35b6971 --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/background.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.100", + "blue" : "0x93", + "green" : "0x8E", + "red" : "0x8E" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x40", + "green" : "0x3A", + "red" : "0x34" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/button.colorset/Contents.json b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/button.colorset/Contents.json new file mode 100644 index 0000000..6681c79 --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Colors/Colors.xcassets/button.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xC2", + "green" : "0x71", + "red" : "0x19" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Extensions/Bundle+DesignSystem.swift b/Packages/DesignSystem/Sources/DesignSystem/Extensions/Bundle+DesignSystem.swift new file mode 100644 index 0000000..b4e3717 --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Extensions/Bundle+DesignSystem.swift @@ -0,0 +1,6 @@ +import Foundation + +extension Bundle { + + static let designSystem = Bundle.module +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Extensions/View+DesignSystem.swift b/Packages/DesignSystem/Sources/DesignSystem/Extensions/View+DesignSystem.swift new file mode 100644 index 0000000..064f301 --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Extensions/View+DesignSystem.swift @@ -0,0 +1,75 @@ +import SwiftUI + +/// Defines how a view should expand to fill available space. +public enum FrameExpansion { + + /// Expands the view to fill all available space horizontally and vertically. + case full + /// Expands the view to fill all available space horizontally only. + case horizontally + /// Expands the view to fill all available space vertically only. + case vertically + + fileprivate var width: CGFloat? { + switch self { + case .full, .horizontally: .infinity + case .vertically: nil + } + } + + fileprivate var height: CGFloat? { + switch self { + case .full, .vertically: .infinity + case .horizontally: nil + } + } +} + +public extension View { + + /// Applies frame expansion to the view based on the specified expansion type. + /// + /// This method provides a convenient way to set a view's maximum width and/or height + /// to infinity, allowing it to expand to fill available space in the specified direction(s). + /// + /// - Parameter expansion: The type of expansion to apply (full, horizontal, or vertical). + /// - Returns: A view with the specified frame expansion applied. + func frame( + _ expansion: FrameExpansion + ) -> some View { + modifier( + ExpandedFrameModifier( + expansion: expansion + ) + ) + } +} + +// MARK: - Private + +private struct ExpandedFrameModifier: ViewModifier { + + // MARK: - Properties + + private let expansion: FrameExpansion + + // MARK: - Lifecycle + + init( + expansion: FrameExpansion + ) { + self.expansion = expansion + } + + // MARK: - Public + + func body( + content: Content + ) -> some View { + content + .frame( + maxWidth: expansion.width, + maxHeight: expansion.height + ) + } +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Factories/ContentUnavailableViewFactory.swift b/Packages/DesignSystem/Sources/DesignSystem/Factories/ContentUnavailableViewFactory.swift new file mode 100644 index 0000000..768e2d5 --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Factories/ContentUnavailableViewFactory.swift @@ -0,0 +1,76 @@ +import SwiftUI + +/// Factory for creating standardized content unavailable views across the application. +public enum ContentUnavailableViewFactory { + + // MARK: - Nested types + + /// Represents different features in the application that may display empty state views. + public enum Feature { + + /// Permanent URLs feature. + case purls + /// Webpage editing feature. + case webpage + /// Now page status feature. + case nowPage + /// Weblog blogging feature. + case weblog + /// Some Pics feature. + case somePics + /// Pastebin paste sharing feature. + case pastebin + } + + // MARK: - Public + + /// Creates a standard view for features that are not yet implemented. + /// + /// - Returns: A ContentUnavailableView indicating the feature is not implemented. + public static func makeNotImplementedView() -> some View { + ContentUnavailableView( + "Not implemented", + systemImage: "wrench.and.screwdriver", + description: Text("This feature was not implemented yet.") + ) + } + + /// Creates an empty state view for a specific feature. + /// + /// - Parameter feature: The feature type to create an empty state view for. + /// - Returns: A ContentUnavailableView with feature-specific messaging and iconography. + public static func makeEmptyFeature(_ feature: Feature) -> some View { + ContentUnavailableView( + "Nothing here yet", + systemImage: feature.systemImage, + description: Text(feature.description) + ) + } +} + +// MARK: - Private + +private extension ContentUnavailableViewFactory.Feature { + + var description: String { + switch self { + case .purls: "Create your first permanent URL to see it listed here." + case .webpage: "Design your webpage to make it available online." + case .nowPage: "Add content to your now page to share what you're up to." + case .weblog: "Start writing your first blog post to see it appear here." + case .somePics: "Upload your first image to see it appear here." + case .pastebin: "Create your first paste to see it appear here." + } + } + + var systemImage: String { + switch self { + case .purls: "link" + case .webpage: "safari" + case .nowPage: "clock" + case .weblog: "text.below.photo" + case .somePics: "photo" + case .pastebin: "clipboard" + } + } +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Styles/Label/DistancedLabelStyle.swift b/Packages/DesignSystem/Sources/DesignSystem/Styles/Label/DistancedLabelStyle.swift new file mode 100644 index 0000000..268ec25 --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Styles/Label/DistancedLabelStyle.swift @@ -0,0 +1,66 @@ +import SwiftUI + +/// A label style that controls the spacing between the icon and title. +/// +/// `DistancedLabelStyle` arranges the label's icon and title horizontally +/// with a customizable distance between them, aligned to the first text baseline. +public struct DistancedLabelStyle: LabelStyle { + + // MARK: - Properties + + private let distance: CGFloat + + // MARK: - Lifecycle + + /// Creates a label style with the specified spacing. + /// + /// - Parameter distance: The horizontal spacing between the icon and title in points. + public init( + distance: CGFloat + ) { + self.distance = distance + } + + // MARK: - Public + + public func makeBody( + configuration: Configuration + ) -> some View { + HStack(alignment: .firstTextBaseline, spacing: distance) { + configuration.icon + configuration.title + } + } +} + +public extension LabelStyle where Self == DistancedLabelStyle { + + // MARK: - Public + + static func distanced( + _ distance: CGFloat + ) -> DistancedLabelStyle { + .init(distance: distance) + } + + static var distanced: DistancedLabelStyle { + .init(distance: 2) + } +} + +// MARK: - Preview + +#Preview("Default value of 2") { + Label("Safari", systemImage: "safari") + .labelStyle(.distanced) +} + +#Preview("Value set to 2") { + Label("Safari", systemImage: "safari") + .labelStyle(.distanced(2)) +} + +#Preview("Value set to 12") { + Label("Safari", systemImage: "safari") + .labelStyle(.distanced(12)) +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Toolbar Items/AddressPickerToolbarItem.swift b/Packages/DesignSystem/Sources/DesignSystem/Toolbar Items/AddressPickerToolbarItem.swift new file mode 100644 index 0000000..8553b20 --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Toolbar Items/AddressPickerToolbarItem.swift @@ -0,0 +1,72 @@ +import SwiftUI + +/// A toolbar item that displays an address picker dropdown menu. +/// +/// `AddressPickerToolbarItem` provides a consistent address selection interface for toolbars +/// across the application. It uses a person.circle icon and displays a dropdown menu of available addresses. +/// +/// ## Usage +/// +/// ```swift +/// .toolbar { +/// ToolbarItemGroup { +/// if showAddressesPicker { +/// AddressPickerToolbarItem( +/// addresses: viewModel.addresses, +/// selection: $viewModel.selectedAddress, +/// helpText: "Select address for paste" +/// ) +/// } +/// } +/// } +/// ``` +/// +/// - Note: The generic `Address` type must conform to `Hashable` for the dropdown menu to work properly. +public struct AddressPickerToolbarItem: View { + + // MARK: - Properties + + private let addresses: [Address] + private let selection: Binding
+ private let helpText: LocalizedStringKey + + // MARK: - Lifecycle + + /// Creates an address picker toolbar item. + /// + /// - Parameters: + /// - addresses: An array of available addresses to choose from. + /// - selection: A binding to the currently selected address. + /// - helpText: The localized help text displayed on hover. Defaults to "Select address". + public init( + addresses: [Address], + selection: Binding
, + helpText: LocalizedStringKey = "Select address" + ) { + self.addresses = addresses + self.selection = selection + self.helpText = helpText + } + + // MARK: - Public + + public var body: some View { + DropdownMenuView( + options: addresses, + selection: selection + ) { + Image(systemName: "person.circle") + .help(helpText) + } + } +} + +// MARK: - Preview + +#Preview { + AddressPickerToolbarItem( + addresses: ["alice", "bob", "charlie"], + selection: .constant("alice"), + helpText: "Select posting address" + ) +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Toolbar Items/ProgressToolbarItem.swift b/Packages/DesignSystem/Sources/DesignSystem/Toolbar Items/ProgressToolbarItem.swift new file mode 100644 index 0000000..4bc0886 --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Toolbar Items/ProgressToolbarItem.swift @@ -0,0 +1,41 @@ +import SwiftUI + +/// A toolbar item that displays a circular progress indicator. +/// +/// `ProgressToolbarItem` provides a consistent progress indicator for toolbars across the application. +/// It uses the `.toolbarButton()` modifier to ensure proper styling and disabled state. +/// +/// ## Usage +/// +/// ```swift +/// .toolbar { +/// ToolbarItemGroup { +/// if isLoading { +/// ProgressToolbarItem() +/// } +/// // Other toolbar items... +/// } +/// } +/// ``` +/// +/// - Note: This component is automatically disabled and styled to fit toolbar contexts. +public struct ProgressToolbarItem: View { + + // MARK: - Lifecycle + + /// Creates a progress toolbar item. + public init() {} + + // MARK: - Public + + public var body: some View { + ProgressView() + .toolbarButton() + } +} + +// MARK: - Preview + +#Preview { + ProgressToolbarItem() +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Toolbar Items/RefreshToolbarItem.swift b/Packages/DesignSystem/Sources/DesignSystem/Toolbar Items/RefreshToolbarItem.swift new file mode 100644 index 0000000..58af658 --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Toolbar Items/RefreshToolbarItem.swift @@ -0,0 +1,86 @@ +import SwiftUI + +/// A toolbar item that displays a refresh button with customizable action and state. +/// +/// `RefreshToolbarItem` provides a consistent refresh button for toolbars across the application. +/// It uses the standard counterclockwise arrow icon and supports custom help text and disabled states. +/// When the button is disabled, which typically indicates a refresh operation is in progress, +/// the icon will animate with a rotation effect. +/// +/// ## Usage +/// +/// ```swift +/// .toolbar { +/// ToolbarItemGroup { +/// RefreshToolbarItem( +/// action: { viewModel.fetchData() }, +/// helpText: "Refresh data", +/// isDisabled: viewModel.isLoading +/// ) +/// } +/// } +/// ``` +/// +/// - Note: The help text parameter accepts `LocalizedStringKey` for automatic localization support. +public struct RefreshToolbarItem: View { + + // MARK: - Properties + + private let action: () -> Void + private let helpText: LocalizedStringKey + private let isDisabled: Bool + + // MARK: - Lifecycle + + /// Creates a refresh toolbar item. + /// + /// - Parameters: + /// - action: The closure to execute when the refresh button is tapped. + /// - helpText: The localized help text displayed on hover. Defaults to "Refresh". + /// - isDisabled: Whether the button should be disabled. When `true`, the icon animates. Defaults to `false`. + public init( + action: @escaping () -> Void, + helpText: LocalizedStringKey = "Refresh", + isDisabled: Bool = false + ) { + self.action = action + self.helpText = helpText + self.isDisabled = isDisabled + } + + // MARK: - Public + + public var body: some View { + Button { + action() + } label: { + Image(systemName: "arrow.counterclockwise") + .rotationEffect(.degrees(isDisabled ? -360 : 0)) + .animation( + isDisabled + ? .linear(duration: 1).repeatForever(autoreverses: false) + : .default, + value: isDisabled + ) + } + .help(helpText) + .disabled(isDisabled) + } +} + +// MARK: - Preview + +#Preview("Enabled") { + RefreshToolbarItem( + action: { print("Refresh tapped") }, + helpText: "Refresh data" + ) +} + +#Preview("Disabled and Animating") { + RefreshToolbarItem( + action: { print("Refresh tapped") }, + helpText: "Refresh data", + isDisabled: true + ) +} diff --git a/Packages/DesignSystem/Sources/DesignSystem/Toolbar Items/SelectionToolbarItem.swift b/Packages/DesignSystem/Sources/DesignSystem/Toolbar Items/SelectionToolbarItem.swift new file mode 100644 index 0000000..0a3ab79 --- /dev/null +++ b/Packages/DesignSystem/Sources/DesignSystem/Toolbar Items/SelectionToolbarItem.swift @@ -0,0 +1,194 @@ +import SwiftUI + +// MARK: - Style Protocol + +/// A protocol that defines the visual style of a selection toolbar item. +/// +/// Conform to this protocol to create custom styles for `SelectionToolbarItem`. +/// The style determines the icon and default help text displayed in the toolbar. +/// +/// ## Creating a Custom Style +/// +/// ```swift +/// struct CustomSelectionToolbarItemStyle: SelectionToolbarItemStyle { +/// func makeLabel(helpText: LocalizedStringKey) -> some View { +/// Image(systemName: "star") +/// .help(helpText) +/// } +/// +/// var defaultHelpText: LocalizedStringKey { +/// "Select option" +/// } +/// } +/// ``` +public protocol SelectionToolbarItemStyle: Sendable { + + /// The type of view representing the label. + associatedtype Label: View + + /// Creates the label view for the toolbar item. + /// + /// - Parameter helpText: The help text to display on hover. + /// - Returns: A view representing the toolbar item's icon. + @ViewBuilder + func makeLabel(helpText: LocalizedStringKey) -> Label + + /// The default help text for this style. + var defaultHelpText: LocalizedStringKey { get } +} + +// MARK: - Filter Style + +/// A style that displays a filter icon (three horizontal lines with decrease symbol). +public struct FilterSelectionToolbarItemStyle: SelectionToolbarItemStyle { + + public init() {} + + public func makeLabel(helpText: LocalizedStringKey) -> some View { + Image(systemName: "line.3.horizontal.decrease") + .help(helpText) + } + + public var defaultHelpText: LocalizedStringKey { + "Filter items" + } +} + +// MARK: - Sort Style + +/// A style that displays a sort icon (up and down arrows). +public struct SortSelectionToolbarItemStyle: SelectionToolbarItemStyle { + + public init() {} + + public func makeLabel(helpText: LocalizedStringKey) -> some View { + Image(systemName: "arrow.up.arrow.down") + .help(helpText) + } + + public var defaultHelpText: LocalizedStringKey { + "Sort items" + } +} + +// MARK: - Environment Key + +private struct SelectionToolbarItemStyleKey: EnvironmentKey { + + static let defaultValue: any SelectionToolbarItemStyle = FilterSelectionToolbarItemStyle() +} + +extension EnvironmentValues { + + var selectionToolbarItemStyle: any SelectionToolbarItemStyle { + get { self[SelectionToolbarItemStyleKey.self] } + set { self[SelectionToolbarItemStyleKey.self] = newValue } + } +} + +// MARK: - View Extension + +public extension View { + + /// Sets the style for selection toolbar items within this view. + /// + /// - Parameter style: The style to apply to selection toolbar items. + /// - Returns: A view with the specified selection toolbar item style applied. + /// + /// ## Example + /// + /// ```swift + /// SelectionToolbarItem( + /// options: Filter.allCases, + /// selection: $filter, + /// itemLabel: { $0.title } + /// ) + /// .selectionToolbarItemStyle(FilterSelectionToolbarItemStyle()) + /// ``` + func selectionToolbarItemStyle(_ style: some SelectionToolbarItemStyle) -> some View { + environment(\.selectionToolbarItemStyle, style) + } +} + +// MARK: - Selection Toolbar Item + +/// A toolbar item that displays a dropdown menu for selecting from various options. +/// +/// `SelectionToolbarItem` is a generic, reusable component that can be styled using +/// the `.selectionToolbarItemStyle()` modifier. It's designed to work with any +/// `Hashable & CaseIterable` type. +/// +/// The visual appearance (icon and default help text) is determined by the applied style. +/// Use `FilterSelectionToolbarItemStyle` for filtering or `SortSelectionToolbarItemStyle` +/// for sorting, or create custom styles conforming to `SelectionToolbarItemStyle`. +/// +/// ## Example Usage +/// +/// ```swift +/// enum ContentFilter: String, CaseIterable { +/// case all, today, thisWeek +/// +/// var title: String { +/// switch self { +/// case .all: return "All" +/// case .today: return "Today" +/// case .thisWeek: return "This Week" +/// } +/// } +/// } +/// +/// SelectionToolbarItem( +/// options: ContentFilter.allCases, +/// selection: $selectedFilter, +/// itemLabel: { $0.title } +/// ) +/// .selectionToolbarItemStyle(FilterSelectionToolbarItemStyle()) +/// ``` +public struct SelectionToolbarItem: View { + + // MARK: - Properties + + @Environment(\.selectionToolbarItemStyle) private var style + + private let options: [Option] + private let selection: Binding