From ce19d00de3c5ec73016b9196d2500a54ea3f729b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrgen=20Kunz?= Date: Fri, 17 Apr 2026 20:46:27 +0200 Subject: [PATCH] Initial social.io Swift app --- .gitignore | 18 + Scripts/capture-controlled-screenshots.sh | 32 + Scripts/ui-screenshot-routes.txt | 6 + Scripts/verify-ios-ui.sh | 24 + SocialIO/README.md | 106 ++ SocialIO/SocialIO.xcodeproj/project.pbxproj | 363 ++++++ SocialIO/Sources/App/AppControlService.swift | 66 + .../Sources/App/AppNavigationCommand.swift | 207 +++ SocialIO/Sources/App/AppViewModel.swift | 256 ++++ SocialIO/Sources/App/SocialIOApp.swift | 19 + SocialIO/Sources/Core/Models/MailModels.swift | 123 ++ .../Core/Services/MockMailService.swift | 187 +++ .../Sources/Features/Mail/MailRootView.swift | 1137 +++++++++++++++++ 13 files changed, 2544 insertions(+) create mode 100644 .gitignore create mode 100755 Scripts/capture-controlled-screenshots.sh create mode 100644 Scripts/ui-screenshot-routes.txt create mode 100755 Scripts/verify-ios-ui.sh create mode 100644 SocialIO/README.md create mode 100644 SocialIO/SocialIO.xcodeproj/project.pbxproj create mode 100644 SocialIO/Sources/App/AppControlService.swift create mode 100644 SocialIO/Sources/App/AppNavigationCommand.swift create mode 100644 SocialIO/Sources/App/AppViewModel.swift create mode 100644 SocialIO/Sources/App/SocialIOApp.swift create mode 100644 SocialIO/Sources/Core/Models/MailModels.swift create mode 100644 SocialIO/Sources/Core/Services/MockMailService.swift create mode 100644 SocialIO/Sources/Features/Mail/MailRootView.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..38e03ee --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +.DS_Store + +# Xcode +DerivedData/ +build/ +*.xcuserstate +*.xcscmblueprint +*.xccheckout +xcuserdata/ +*.moved-aside + +# SwiftPM +.build/ +.swiftpm/ +Package.resolved + +# App artifacts +compose-screen.png diff --git a/Scripts/capture-controlled-screenshots.sh b/Scripts/capture-controlled-screenshots.sh new file mode 100755 index 0000000..a1d1a86 --- /dev/null +++ b/Scripts/capture-controlled-screenshots.sh @@ -0,0 +1,32 @@ +#!/bin/zsh + +set -euo pipefail + +script_dir="$(cd -- "$(dirname "$0")" && pwd)" +device="${1:-booted}" +output_dir="${2:-/tmp/socialio-controlled-shots}" +control_file="${SOCIALIO_CONTROL_FILE:-/tmp/socialio-control.txt}" +routes_file="${SOCIALIO_SCREENSHOT_ROUTES:-$script_dir/ui-screenshot-routes.txt}" +bundle_id="io.social.app" + +mkdir -p "$output_dir" + +printf '%s\n' 'socialio://mailbox/inbox' > "$control_file" + +SIMCTL_CHILD_SOCIALIO_CONTROL_FILE="$control_file" \ +SIMCTL_CHILD_SOCIALIO_CONTROL_POLL_MS="250" \ +xcrun simctl launch --terminate-running-process "$device" "$bundle_id" >/dev/null + +sleep 2 + +while IFS='|' read -r name route; do + if [[ -z "${name}" || "${name}" == \#* ]]; then + continue + fi + + printf '%s\n' "$route" > "$control_file" + sleep 1.2 + xcrun simctl io "$device" screenshot "$output_dir/$name.png" >/dev/null +done < "$routes_file" + +echo "Saved screenshots to $output_dir" diff --git a/Scripts/ui-screenshot-routes.txt b/Scripts/ui-screenshot-routes.txt new file mode 100644 index 0000000..8878437 --- /dev/null +++ b/Scripts/ui-screenshot-routes.txt @@ -0,0 +1,6 @@ +# name|route +inbox|socialio://mailbox/inbox +starred|socialio://mailbox/starred?unreadOnly=true +launch-copy|socialio://open?thread=launch-copy&message=launch-copy-2 +investor-update|socialio://open?thread=investor-update&message=investor-update-1 +compose-grandma|socialio://compose?to=grandma@example.com&subject=Family%20Photos&body=Hi%20Grandma%2C%0A%0AI%20pulled%20up%20the%20photos%20thread%20for%20you. diff --git a/Scripts/verify-ios-ui.sh b/Scripts/verify-ios-ui.sh new file mode 100755 index 0000000..3461612 --- /dev/null +++ b/Scripts/verify-ios-ui.sh @@ -0,0 +1,24 @@ +#!/bin/zsh + +set -euo pipefail + +script_dir="$(cd -- "$(dirname "$0")" && pwd)" +device="${1:-8EBCDD58-34AB-457A-A878-8004A6108CA9}" +output_dir="${2:-/tmp/socialio-ui-review}" +derived_data="${3:-/tmp/socialio-ui-review-derived}" +project="/Users/philkunz/gitea/social.io-swiftapp/SocialIO/SocialIO.xcodeproj" +app_path="$derived_data/Build/Products/Debug-iphonesimulator/SocialIO.app" + +xcodebuild \ + -project "$project" \ + -scheme SocialIO \ + -configuration Debug \ + -destination "id=$device" \ + -derivedDataPath "$derived_data" \ + build + +xcrun simctl install "$device" "$app_path" +"$script_dir/capture-controlled-screenshots.sh" "$device" "$output_dir" + +echo "Built app at $app_path" +echo "Saved screenshots to $output_dir" diff --git a/SocialIO/README.md b/SocialIO/README.md new file mode 100644 index 0000000..4c973c7 --- /dev/null +++ b/SocialIO/README.md @@ -0,0 +1,106 @@ +# SocialIO Swift App + +Multiplatform SwiftUI mail client scaffold for macOS, iPadOS, and iOS. + +## What is included + +- One shared SwiftUI app target +- Mocked mail backend with seeded conversations +- Three-column mail UI that adapts across Apple platforms +- Compose flow, search, unread filters, favorites, and message detail +- Backend-driven navigation hooks for mailboxes, threads, and compose flows +- Screenshot/test automation route IDs and accessibility identifiers + +## Open the project + +1. Install/select full Xcode on this Mac. +2. Open `SocialIO/SocialIO.xcodeproj`. +3. Build the `SocialIO` scheme for: + - `My Mac` + - an iPad simulator + - an iPhone simulator + +## App control contract + +The app can be driven in three ways: + +- Launch with `SOCIALIO_ROUTE` +- Launch with `SOCIALIO_COMMAND_JSON` +- Keep a running app subscribed to a mocked backend control file with `SOCIALIO_CONTROL_FILE` + +### Deep link examples + +```text +socialio://mailbox/inbox +socialio://mailbox/starred?unreadOnly=true +socialio://thread/launch-copy +socialio://open?thread=launch-copy&message=launch-copy-2 +socialio://compose?to=grandma@example.com&subject=Family%20Photos&body=Hi%20Grandma +``` + +### JSON command examples + +```json +{"kind":"mailbox","mailbox":"archive","search":"roadmap"} +{"kind":"thread","threadID":"launch-copy","messageID":"launch-copy-2"} +{"kind":"compose","to":"grandma@example.com","subject":"Family Photos","body":"Hi Grandma"} +``` + +### Stable mock route IDs + +- Threads: `launch-copy`, `daily-sync-status`, `investor-update`, `search-ranking-polish`, `welcome-to-socialio`, `roadmap-notes` +- Messages: `launch-copy-1`, `launch-copy-2`, `investor-update-1`, `roadmap-notes-1`, and similar seeded IDs + +## Mock backend control + +When `SOCIALIO_CONTROL_FILE` points at a text file, the running app polls it and applies the latest command whenever the file contents change. + +Examples: + +```bash +echo 'socialio://open?thread=launch-copy&message=launch-copy-2' > /tmp/socialio-control.txt +echo '{"kind":"compose","to":"grandma@example.com","subject":"Photos","body":"Hi Grandma"}' > /tmp/socialio-control.txt +``` + +That gives us a mocked backend transport now, and we can swap the same command model behind a real API later. + +## Screenshot automation + +After the app is built and installed in Simulator, run: + +```bash +/Users/philkunz/gitea/social.io-swiftapp/Scripts/capture-controlled-screenshots.sh booted /tmp/socialio-shots +``` + +The script launches the app with `SOCIALIO_CONTROL_FILE`, rewrites that file with a series of routes, and saves screenshots for each destination. + +## Standard UI review loop + +For UI-affecting changes, use the one-shot verification script: + +```bash +/Users/philkunz/gitea/social.io-swiftapp/Scripts/verify-ios-ui.sh +``` + +That standard flow: + +- builds the iPhone simulator app +- installs it into Simulator +- runs the backend-control screenshot pass +- saves the review set to `/tmp/socialio-ui-review` + +The screenshot list lives in `/Users/philkunz/gitea/social.io-swiftapp/Scripts/ui-screenshot-routes.txt`, so we can keep expanding the review set as the app grows. + +## UI test hooks + +Key controls now expose stable accessibility identifiers such as: + +- `mailbox.inbox` +- `filter.unread` +- `thread.launch-copy` +- `message.launch-copy-2` +- `compose.view` +- `compose.to` +- `compose.subject` +- `compose.body` +- `compose.send` diff --git a/SocialIO/SocialIO.xcodeproj/project.pbxproj b/SocialIO/SocialIO.xcodeproj/project.pbxproj new file mode 100644 index 0000000..efd70e5 --- /dev/null +++ b/SocialIO/SocialIO.xcodeproj/project.pbxproj @@ -0,0 +1,363 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + A10000000000000000000001 /* SocialIOApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000001 /* SocialIOApp.swift */; }; + A10000000000000000000002 /* AppViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000002 /* AppViewModel.swift */; }; + A10000000000000000000003 /* MailModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000003 /* MailModels.swift */; }; + A10000000000000000000004 /* MockMailService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000004 /* MockMailService.swift */; }; + A10000000000000000000005 /* MailRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000005 /* MailRootView.swift */; }; + A10000000000000000000006 /* AppNavigationCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000007 /* AppNavigationCommand.swift */; }; + A10000000000000000000007 /* AppControlService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000008 /* AppControlService.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + A20000000000000000000001 /* SocialIOApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialIOApp.swift; sourceTree = ""; }; + A20000000000000000000002 /* AppViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewModel.swift; sourceTree = ""; }; + A20000000000000000000003 /* MailModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailModels.swift; sourceTree = ""; }; + A20000000000000000000004 /* MockMailService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockMailService.swift; sourceTree = ""; }; + A20000000000000000000005 /* MailRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailRootView.swift; sourceTree = ""; }; + A20000000000000000000006 /* SocialIO.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SocialIO.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A20000000000000000000007 /* AppNavigationCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNavigationCommand.swift; sourceTree = ""; }; + A20000000000000000000008 /* AppControlService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppControlService.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + A30000000000000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + A40000000000000000000001 = { + isa = PBXGroup; + children = ( + A40000000000000000000002 /* SocialIO */, + A40000000000000000000009 /* Products */, + ); + sourceTree = ""; + }; + A40000000000000000000002 /* SocialIO */ = { + isa = PBXGroup; + children = ( + A40000000000000000000003 /* Sources */, + ); + name = SocialIO; + sourceTree = ""; + }; + A40000000000000000000003 /* Sources */ = { + isa = PBXGroup; + children = ( + A40000000000000000000004 /* App */, + A40000000000000000000005 /* Core */, + A40000000000000000000008 /* Features */, + ); + path = Sources; + sourceTree = ""; + }; + A40000000000000000000004 /* App */ = { + isa = PBXGroup; + children = ( + A20000000000000000000001 /* SocialIOApp.swift */, + A20000000000000000000002 /* AppViewModel.swift */, + A20000000000000000000007 /* AppNavigationCommand.swift */, + A20000000000000000000008 /* AppControlService.swift */, + ); + path = App; + sourceTree = ""; + }; + A40000000000000000000005 /* Core */ = { + isa = PBXGroup; + children = ( + A40000000000000000000006 /* Models */, + A40000000000000000000007 /* Services */, + ); + path = Core; + sourceTree = ""; + }; + A40000000000000000000006 /* Models */ = { + isa = PBXGroup; + children = ( + A20000000000000000000003 /* MailModels.swift */, + ); + path = Models; + sourceTree = ""; + }; + A40000000000000000000007 /* Services */ = { + isa = PBXGroup; + children = ( + A20000000000000000000004 /* MockMailService.swift */, + ); + path = Services; + sourceTree = ""; + }; + A40000000000000000000008 /* Features */ = { + isa = PBXGroup; + children = ( + A4000000000000000000000A /* Mail */, + ); + path = Features; + sourceTree = ""; + }; + A40000000000000000000009 /* Products */ = { + isa = PBXGroup; + children = ( + A20000000000000000000006 /* SocialIO.app */, + ); + name = Products; + sourceTree = ""; + }; + A4000000000000000000000A /* Mail */ = { + isa = PBXGroup; + children = ( + A20000000000000000000005 /* MailRootView.swift */, + ); + path = Mail; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A50000000000000000000001 /* SocialIO */ = { + isa = PBXNativeTarget; + buildConfigurationList = A70000000000000000000002 /* Build configuration list for PBXNativeTarget "SocialIO" */; + buildPhases = ( + A30000000000000000000002 /* Sources */, + A30000000000000000000001 /* Frameworks */, + A30000000000000000000003 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = SocialIO; + productName = SocialIO; + productReference = A20000000000000000000006 /* SocialIO.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A60000000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2600; + LastUpgradeCheck = 2600; + TargetAttributes = { + A50000000000000000000001 = { + CreatedOnToolsVersion = 26.0; + }; + }; + }; + buildConfigurationList = A70000000000000000000001 /* Build configuration list for PBXProject "SocialIO" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = A40000000000000000000001; + productRefGroup = A40000000000000000000009 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A50000000000000000000001 /* SocialIO */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A30000000000000000000003 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A30000000000000000000002 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A10000000000000000000002 /* AppViewModel.swift in Sources */, + A10000000000000000000005 /* MailRootView.swift in Sources */, + A10000000000000000000003 /* MailModels.swift in Sources */, + A10000000000000000000004 /* MockMailService.swift in Sources */, + A10000000000000000000001 /* SocialIOApp.swift in Sources */, + A10000000000000000000006 /* AppNavigationCommand.swift in Sources */, + A10000000000000000000007 /* AppControlService.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + A80000000000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + SDKROOT = auto; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + A80000000000000000000002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + SDKROOT = auto; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + A80000000000000000000003 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = SocialIO; + INFOPLIST_KEY_CFBundleURLTypes = ( + { + CFBundleTypeRole = Editor; + CFBundleURLName = io.social.app; + CFBundleURLSchemes = ( + socialio, + ); + }, + ); + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.social.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBSERVATION_ENABLED = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + A80000000000000000000004 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = SocialIO; + INFOPLIST_KEY_CFBundleURLTypes = ( + { + CFBundleTypeRole = Editor; + CFBundleURLName = io.social.app; + CFBundleURLSchemes = ( + socialio, + ); + }, + ); + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = io.social.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBSERVATION_ENABLED = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A70000000000000000000001 /* Build configuration list for PBXProject "SocialIO" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A80000000000000000000001 /* Debug */, + A80000000000000000000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A70000000000000000000002 /* Build configuration list for PBXNativeTarget "SocialIO" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A80000000000000000000003 /* Debug */, + A80000000000000000000004 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = A60000000000000000000001 /* Project object */; +} diff --git a/SocialIO/Sources/App/AppControlService.swift b/SocialIO/Sources/App/AppControlService.swift new file mode 100644 index 0000000..b905fa6 --- /dev/null +++ b/SocialIO/Sources/App/AppControlService.swift @@ -0,0 +1,66 @@ +import Foundation + +protocol AppControlServicing { + func commands() -> AsyncStream +} + +struct MockBackendControlService: AppControlServicing { + static let controlFileEnvironmentKey = "SOCIALIO_CONTROL_FILE" + static let pollingIntervalEnvironmentKey = "SOCIALIO_CONTROL_POLL_MS" + + private let environment: [String: String] + + init(environment: [String: String] = ProcessInfo.processInfo.environment) { + self.environment = environment + } + + func commands() -> AsyncStream { + guard let controlFilePath = environment[Self.controlFileEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !controlFilePath.isEmpty else { + return AsyncStream { continuation in + continuation.finish() + } + } + + let controlFileURL = URL(fileURLWithPath: controlFilePath) + let pollingInterval = pollingIntervalDuration + + return AsyncStream { continuation in + let task = Task.detached(priority: .background) { + var lastAppliedPayload: String? + + while !Task.isCancelled { + if let payload = try? String(contentsOf: controlFileURL, encoding: .utf8) { + let trimmedPayload = payload.trimmingCharacters(in: .whitespacesAndNewlines) + + if !trimmedPayload.isEmpty, + trimmedPayload != lastAppliedPayload, + let command = AppNavigationCommand.parse(trimmedPayload) { + lastAppliedPayload = trimmedPayload + continuation.yield(command) + } + } + + try? await Task.sleep(for: pollingInterval) + } + + continuation.finish() + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } + + private var pollingIntervalDuration: Duration { + guard let rawValue = environment[Self.pollingIntervalEnvironmentKey], + let milliseconds = Int(rawValue), + milliseconds > 0 else { + return .milliseconds(600) + } + + return .milliseconds(milliseconds) + } +} diff --git a/SocialIO/Sources/App/AppNavigationCommand.swift b/SocialIO/Sources/App/AppNavigationCommand.swift new file mode 100644 index 0000000..6ae76c8 --- /dev/null +++ b/SocialIO/Sources/App/AppNavigationCommand.swift @@ -0,0 +1,207 @@ +import Foundation + +enum AppNavigationCommand: Equatable { + case mailbox(mailbox: Mailbox, search: String?, unreadOnly: Bool?) + case thread( + threadRouteID: String, + mailbox: Mailbox?, + messageRouteID: String?, + search: String?, + unreadOnly: Bool? + ) + case compose(draft: ComposeDraft) + + static let routeEnvironmentKey = "SOCIALIO_ROUTE" + static let jsonEnvironmentKey = "SOCIALIO_COMMAND_JSON" + + static func from(environment: [String: String]) -> AppNavigationCommand? { + if let json = environment[jsonEnvironmentKey] { + return from(json: json) + } + + if let route = environment[routeEnvironmentKey] { + return parse(route) + } + + return nil + } + + static func parse(_ rawValue: String) -> AppNavigationCommand? { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + if trimmed.hasPrefix("{") { + return from(json: trimmed) + } + + guard let url = URL(string: trimmed) else { return nil } + return from(url: url) + } + + static func from(url: URL) -> AppNavigationCommand? { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return nil + } + + let host = (components.host ?? "").lowercased() + let pathComponents = url.path + .split(separator: "/") + .map(String.init) + + let queryItems = components.queryItems ?? [] + let mailbox = queryItems.value(named: "mailbox").flatMap(Mailbox.init(rawValue:)) + let threadRouteID = queryItems.value(named: "thread") + let messageRouteID = queryItems.value(named: "message") + let search = queryItems.value(named: "search") + let unreadOnly = queryItems.value(named: "unreadOnly").flatMap(Bool.init) + + switch host { + case "mailbox": + guard let mailboxID = pathComponents.first, let mailbox = Mailbox(rawValue: mailboxID) else { + return nil + } + return .mailbox(mailbox: mailbox, search: search, unreadOnly: unreadOnly) + + case "thread": + guard let routeID = pathComponents.first else { return nil } + return .thread( + threadRouteID: routeID, + mailbox: mailbox, + messageRouteID: messageRouteID, + search: search, + unreadOnly: unreadOnly + ) + + case "compose": + return .compose( + draft: ComposeDraft( + to: queryItems.value(named: "to") ?? "", + subject: queryItems.value(named: "subject") ?? "", + body: queryItems.value(named: "body") ?? "" + ) + ) + + case "open", "": + if let threadRouteID { + return .thread( + threadRouteID: threadRouteID, + mailbox: mailbox, + messageRouteID: messageRouteID, + search: search, + unreadOnly: unreadOnly + ) + } + + if let mailbox { + return .mailbox(mailbox: mailbox, search: search, unreadOnly: unreadOnly) + } + + if queryItems.value(named: "to") != nil || + queryItems.value(named: "subject") != nil || + queryItems.value(named: "body") != nil { + return .compose( + draft: ComposeDraft( + to: queryItems.value(named: "to") ?? "", + subject: queryItems.value(named: "subject") ?? "", + body: queryItems.value(named: "body") ?? "" + ) + ) + } + + return nil + + default: + return nil + } + } + + static func from(json: String) -> AppNavigationCommand? { + guard let data = json.data(using: .utf8) else { return nil } + let decoder = JSONDecoder() + + do { + let payload = try decoder.decode(AppNavigationPayload.self, from: data) + return payload.command + } catch { + return nil + } + } +} + +private struct AppNavigationPayload: Decodable { + enum Kind: String, Decodable { + case mailbox + case thread + case compose + } + + let kind: Kind? + let mailbox: Mailbox? + let threadID: String? + let messageID: String? + let search: String? + let unreadOnly: Bool? + let to: String? + let subject: String? + let body: String? + + var command: AppNavigationCommand? { + switch kind { + case .mailbox: + guard let mailbox else { return nil } + return .mailbox(mailbox: mailbox, search: search, unreadOnly: unreadOnly) + + case .thread: + guard let threadID else { return nil } + return .thread( + threadRouteID: threadID, + mailbox: mailbox, + messageRouteID: messageID, + search: search, + unreadOnly: unreadOnly + ) + + case .compose: + return .compose( + draft: ComposeDraft( + to: to ?? "", + subject: subject ?? "", + body: body ?? "" + ) + ) + + case nil: + if let threadID { + return .thread( + threadRouteID: threadID, + mailbox: mailbox, + messageRouteID: messageID, + search: search, + unreadOnly: unreadOnly + ) + } + + if let mailbox { + return .mailbox(mailbox: mailbox, search: search, unreadOnly: unreadOnly) + } + + if to != nil || subject != nil || body != nil { + return .compose( + draft: ComposeDraft( + to: to ?? "", + subject: subject ?? "", + body: body ?? "" + ) + ) + } + + return nil + } + } +} + +private extension [URLQueryItem] { + func value(named name: String) -> String? { + first(where: { $0.name == name })?.value + } +} diff --git a/SocialIO/Sources/App/AppViewModel.swift b/SocialIO/Sources/App/AppViewModel.swift new file mode 100644 index 0000000..6c69a3f --- /dev/null +++ b/SocialIO/Sources/App/AppViewModel.swift @@ -0,0 +1,256 @@ +import Foundation +import Observation + +@MainActor +@Observable +final class AppViewModel { + var selectedMailbox: Mailbox = .inbox + var selectedThreadID: MailThread.ID? + var focusedMessageRouteID: String? + var searchText = "" + var showUnreadOnly = false + var isComposing = false + var composeDraft = ComposeDraft() + var threads: [MailThread] = [] + var isLoading = false + var errorMessage: String? + var mailboxNavigationToken = UUID() + var threadNavigationToken = UUID() + + private let service: MailServicing + private let controlService: AppControlServicing + private var pendingNavigationCommand: AppNavigationCommand? + private var isListeningForBackendCommands = false + + init( + service: MailServicing = MockMailService(), + controlService: AppControlServicing = MockBackendControlService() + ) { + self.service = service + self.controlService = controlService + if let command = AppNavigationCommand.from(environment: ProcessInfo.processInfo.environment) { + apply(command: command) + } + } + + var selectedThread: MailThread? { + get { threads.first(where: { $0.id == selectedThreadID }) } + set { selectedThreadID = newValue?.id } + } + + var filteredThreads: [MailThread] { + threads + .filter { thread in + selectedMailbox == .starred ? thread.isStarred : thread.mailbox == selectedMailbox + } + .filter { thread in + !showUnreadOnly || thread.isUnread + } + .filter(matchesSearch) + .sorted { $0.lastUpdated > $1.lastUpdated } + } + + var totalUnreadCount: Int { + threads.filter(\.isUnread).count + } + + func threadCount(in mailbox: Mailbox) -> Int { + threads.filter { thread in + mailbox == .starred ? thread.isStarred : thread.mailbox == mailbox + } + .count + } + + func unreadCount(in mailbox: Mailbox) -> Int { + threads.filter { thread in + let matchesMailbox = mailbox == .starred ? thread.isStarred : thread.mailbox == mailbox + return matchesMailbox && thread.isUnread + } + .count + } + + func load() async { + guard threads.isEmpty else { return } + + isLoading = true + defer { isLoading = false } + + do { + threads = try await service.loadThreads() + + if let command = pendingNavigationCommand { + pendingNavigationCommand = nil + apply(command: command) + } else { + reconcileSelectionForCurrentFilters() + } + } catch { + errorMessage = "Unable to load mail." + } + } + + func toggleStar(for thread: MailThread) { + toggleStar(forThreadID: thread.id) + } + + func toggleStar(forThreadID threadID: MailThread.ID) { + guard let index = threads.firstIndex(where: { $0.id == threadID }) else { return } + var updatedThread = threads[index] + updatedThread.isStarred.toggle() + threads[index] = updatedThread + reconcileSelectionForCurrentFilters() + } + + func toggleRead(for thread: MailThread) { + toggleRead(forThreadID: thread.id) + } + + func toggleRead(forThreadID threadID: MailThread.ID) { + guard let index = threads.firstIndex(where: { $0.id == threadID }) else { return } + var updatedThread = threads[index] + updatedThread.isUnread.toggle() + threads[index] = updatedThread + reconcileSelectionForCurrentFilters() + } + + func selectMailbox(_ mailbox: Mailbox) { + selectedMailbox = mailbox + clearThreadSelection() + mailboxNavigationToken = UUID() + } + + func setUnreadOnly(_ unreadOnly: Bool) { + showUnreadOnly = unreadOnly + clearThreadSelection() + mailboxNavigationToken = UUID() + } + + func setSearchText(_ text: String) { + searchText = text + reconcileSelectionForCurrentFilters() + } + + func startCompose() { + composeDraft = ComposeDraft() + focusedMessageRouteID = nil + isComposing = true + } + + func openThread(withID threadID: MailThread.ID, focusedMessageRouteID: String? = nil) { + guard let thread = thread(withID: threadID) else { return } + + selectedThreadID = threadID + + if let focusedMessageRouteID, + thread.messages.contains(where: { $0.routeID == focusedMessageRouteID }) { + self.focusedMessageRouteID = focusedMessageRouteID + } else { + self.focusedMessageRouteID = nil + } + + threadNavigationToken = UUID() + } + + func dismissThreadSelection() { + clearThreadSelection() + } + + func beginBackendControl() async { + guard !isListeningForBackendCommands else { return } + isListeningForBackendCommands = true + + for await command in controlService.commands() { + apply(command: command) + } + } + + func apply(url: URL) { + guard let command = AppNavigationCommand.from(url: url) else { + errorMessage = "Unable to open requested destination." + return + } + + apply(command: command) + } + + func apply(command: AppNavigationCommand) { + switch command { + case let .mailbox(mailbox, search, unreadOnly): + isComposing = false + searchText = search ?? "" + showUnreadOnly = unreadOnly ?? false + selectMailbox(mailbox) + + case let .thread(threadRouteID, mailbox, messageRouteID, search, unreadOnly): + guard !threads.isEmpty else { + pendingNavigationCommand = command + return + } + + searchText = search ?? "" + showUnreadOnly = unreadOnly ?? false + isComposing = false + + guard let thread = threads.first(where: { $0.routeID == threadRouteID }) else { + errorMessage = "Unable to open requested conversation." + return + } + + selectedMailbox = mailbox ?? thread.mailbox + openThread(withID: thread.id, focusedMessageRouteID: messageRouteID) + + case let .compose(draft): + focusedMessageRouteID = nil + composeDraft = draft + isComposing = true + } + } + + func sendCurrentDraft() async { + let draft = composeDraft + isComposing = false + + do { + let sentThread = try await service.send(draft: draft) + threads.insert(sentThread, at: 0) + selectedMailbox = .sent + openThread(withID: sentThread.id) + } catch { + errorMessage = "Unable to send message." + } + } + + private func matchesSearch(thread: MailThread) -> Bool { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return true } + + let haystack = [ + thread.subject, + thread.previewText, + thread.participants.map(\.name).joined(separator: " "), + thread.tags.joined(separator: " ") + ] + .joined(separator: " ") + .localizedLowercase + + return haystack.contains(query.localizedLowercase) + } + + func thread(withID threadID: MailThread.ID) -> MailThread? { + threads.first(where: { $0.id == threadID }) + } + + private func clearThreadSelection() { + selectedThreadID = nil + focusedMessageRouteID = nil + } + + private func reconcileSelectionForCurrentFilters() { + if let selectedThreadID, + filteredThreads.contains(where: { $0.id == selectedThreadID }) { + return + } + + clearThreadSelection() + } +} diff --git a/SocialIO/Sources/App/SocialIOApp.swift b/SocialIO/Sources/App/SocialIOApp.swift new file mode 100644 index 0000000..eac9789 --- /dev/null +++ b/SocialIO/Sources/App/SocialIOApp.swift @@ -0,0 +1,19 @@ +import SwiftUI + +@main +struct SocialIOApp: App { + @State private var model = AppViewModel() + + var body: some Scene { + WindowGroup { + MailRootView(model: model) + .tint(MailTheme.accent) + .onOpenURL { url in + model.apply(url: url) + } + } + #if os(macOS) + .defaultSize(width: 1440, height: 900) + #endif + } +} diff --git a/SocialIO/Sources/Core/Models/MailModels.swift b/SocialIO/Sources/Core/Models/MailModels.swift new file mode 100644 index 0000000..8d6f821 --- /dev/null +++ b/SocialIO/Sources/Core/Models/MailModels.swift @@ -0,0 +1,123 @@ +import Foundation + +enum Mailbox: String, CaseIterable, Identifiable, Codable { + case inbox + case starred + case sent + case drafts + case archive + + var id: String { rawValue } + + var title: String { + switch self { + case .inbox: "Inbox" + case .starred: "Starred" + case .sent: "Sent" + case .drafts: "Drafts" + case .archive: "Archive" + } + } + + var systemImage: String { + switch self { + case .inbox: "tray.full" + case .starred: "star" + case .sent: "paperplane" + case .drafts: "doc.text" + case .archive: "archivebox" + } + } +} + +struct MailPerson: Identifiable, Hashable, Codable { + let id: UUID + let name: String + let email: String + + init(id: UUID = UUID(), name: String, email: String) { + self.id = id + self.name = name + self.email = email + } +} + +struct MailMessage: Identifiable, Hashable, Codable { + let id: UUID + let routeID: String + let sender: MailPerson + let recipients: [MailPerson] + let sentAt: Date + let body: String + let isDraft: Bool + + init( + id: UUID = UUID(), + routeID: String = UUID().uuidString.lowercased(), + sender: MailPerson, + recipients: [MailPerson], + sentAt: Date, + body: String, + isDraft: Bool = false + ) { + self.id = id + self.routeID = routeID + self.sender = sender + self.recipients = recipients + self.sentAt = sentAt + self.body = body + self.isDraft = isDraft + } +} + +struct MailThread: Identifiable, Hashable, Codable { + let id: UUID + let routeID: String + var mailbox: Mailbox + var subject: String + var participants: [MailPerson] + var messages: [MailMessage] + var isUnread: Bool + var isStarred: Bool + var tags: [String] + + init( + id: UUID = UUID(), + routeID: String = UUID().uuidString.lowercased(), + mailbox: Mailbox, + subject: String, + participants: [MailPerson], + messages: [MailMessage], + isUnread: Bool, + isStarred: Bool, + tags: [String] = [] + ) { + self.id = id + self.routeID = routeID + self.mailbox = mailbox + self.subject = subject + self.participants = participants + self.messages = messages.sorted { $0.sentAt < $1.sentAt } + self.isUnread = isUnread + self.isStarred = isStarred + self.tags = tags + } + + var latestMessage: MailMessage? { + messages.max(by: { $0.sentAt < $1.sentAt }) + } + + var previewText: String { + latestMessage?.body.replacingOccurrences(of: "\n", with: " ") ?? "" + } + + var lastUpdated: Date { + latestMessage?.sentAt ?? .distantPast + } +} + +struct ComposeDraft: Equatable { + var to = "" + var subject = "" + var body = "" +} diff --git a/SocialIO/Sources/Core/Services/MockMailService.swift b/SocialIO/Sources/Core/Services/MockMailService.swift new file mode 100644 index 0000000..8219786 --- /dev/null +++ b/SocialIO/Sources/Core/Services/MockMailService.swift @@ -0,0 +1,187 @@ +import Foundation + +protocol MailServicing { + func loadThreads() async throws -> [MailThread] + func send(draft: ComposeDraft) async throws -> MailThread +} + +struct MockMailService: MailServicing { + private let me = MailPerson(name: "Phil Kunz", email: "phil@social.io") + + func loadThreads() async throws -> [MailThread] { + try await Task.sleep(for: .milliseconds(150)) + return seededThreads.sorted { $0.lastUpdated > $1.lastUpdated } + } + + func send(draft: ComposeDraft) async throws -> MailThread { + try await Task.sleep(for: .milliseconds(120)) + + let threadRouteID = "sent-\(UUID().uuidString.lowercased())" + let messageRouteID = "\(threadRouteID)-message" + + let recipientNames = draft.to + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + let recipients = recipientNames.map { raw in + MailPerson( + name: raw.components(separatedBy: "@").first?.capitalized ?? raw, + email: raw + ) + } + + let message = MailMessage( + routeID: messageRouteID, + sender: me, + recipients: recipients, + sentAt: .now, + body: draft.body + ) + + return MailThread( + routeID: threadRouteID, + mailbox: .sent, + subject: draft.subject.isEmpty ? "(No Subject)" : draft.subject, + participants: recipients + [me], + messages: [message], + isUnread: false, + isStarred: false, + tags: ["Sent"] + ) + } + + private var seededThreads: [MailThread] { + let alex = MailPerson(name: "Alex Rivera", email: "alex@social.io") + let nora = MailPerson(name: "Nora Chen", email: "nora@social.io") + let tanya = MailPerson(name: "Tanya Hall", email: "tanya@design.social.io") + let ops = MailPerson(name: "Ops Bot", email: "ops@social.io") + let investor = MailPerson(name: "Mina Park", email: "mina@northshore.vc") + + return [ + MailThread( + routeID: "launch-copy", + mailbox: .inbox, + subject: "Launch copy for the onboarding flow", + participants: [tanya, me], + messages: [ + MailMessage( + routeID: "launch-copy-1", + sender: tanya, + recipients: [me], + sentAt: .now.addingTimeInterval(-3600 * 2), + body: "I tightened the onboarding copy and added a warmer empty-state tone. If you're good with it, I can hand the strings to the app team today." + ), + MailMessage( + routeID: "launch-copy-2", + sender: me, + recipients: [tanya], + sentAt: .now.addingTimeInterval(-3600), + body: "Looks strong. Let's keep the playful language on iPhone and simplify the desktop first-run copy a bit." + ) + ], + isUnread: true, + isStarred: true, + tags: ["Design", "Launch"] + ), + MailThread( + routeID: "daily-sync-status", + mailbox: .inbox, + subject: "Daily inbox sync status", + participants: [ops, me], + messages: [ + MailMessage( + routeID: "daily-sync-status-1", + sender: ops, + recipients: [me], + sentAt: .now.addingTimeInterval(-3600 * 4), + body: "Mock sync complete. 1,284 messages mirrored from staging. Attachment fetch remains disabled in the sandbox profile." + ) + ], + isUnread: false, + isStarred: false, + tags: ["System"] + ), + MailThread( + routeID: "investor-update", + mailbox: .inbox, + subject: "Investor update before next Friday", + participants: [investor, me], + messages: [ + MailMessage( + routeID: "investor-update-1", + sender: investor, + recipients: [me], + sentAt: .now.addingTimeInterval(-3600 * 26), + body: "Could you send a concise product update and a few screenshots of the new mail experience before Friday? Interested in how you are differentiating from commodity inboxes." + ) + ], + isUnread: true, + isStarred: false, + tags: ["External"] + ), + MailThread( + routeID: "search-ranking-polish", + mailbox: .sent, + subject: "Re: Search ranking polish", + participants: [alex, me], + messages: [ + MailMessage( + routeID: "search-ranking-polish-1", + sender: alex, + recipients: [me], + sentAt: .now.addingTimeInterval(-3600 * 30), + body: "The current search sort is useful, but I still feel too much recency over intent." + ), + MailMessage( + routeID: "search-ranking-polish-2", + sender: me, + recipients: [alex], + sentAt: .now.addingTimeInterval(-3600 * 28), + body: "Agreed. I want us to bias toward active collaborators and threads with lightweight action language like review, approve, or ship." + ) + ], + isUnread: false, + isStarred: false, + tags: ["Search"] + ), + MailThread( + routeID: "welcome-to-socialio", + mailbox: .drafts, + subject: "Welcome to social.io mail", + participants: [me, nora], + messages: [ + MailMessage( + routeID: "welcome-to-socialio-1", + sender: me, + recipients: [nora], + sentAt: .now.addingTimeInterval(-3600 * 6), + body: "Thanks for joining the early design partner group. Here is a quick overview of our calm, collaborative take on email...", + isDraft: true + ) + ], + isUnread: false, + isStarred: false, + tags: ["Draft"] + ), + MailThread( + routeID: "roadmap-notes", + mailbox: .archive, + subject: "Roadmap notes from product sync", + participants: [nora, alex, me], + messages: [ + MailMessage( + routeID: "roadmap-notes-1", + sender: nora, + recipients: [alex, me], + sentAt: .now.addingTimeInterval(-3600 * 72), + body: "Captured the big roadmap themes: faster triage, identity-rich threads, and calmer notifications. I archived the raw notes after cleanup." + ) + ], + isUnread: false, + isStarred: true, + tags: ["Product"] + ) + ] + } +} diff --git a/SocialIO/Sources/Features/Mail/MailRootView.swift b/SocialIO/Sources/Features/Mail/MailRootView.swift new file mode 100644 index 0000000..91b8d2e --- /dev/null +++ b/SocialIO/Sources/Features/Mail/MailRootView.swift @@ -0,0 +1,1137 @@ +import SwiftUI +#if os(macOS) +import AppKit +#else +import UIKit +#endif + +enum MailTheme { + static let accent = Color(red: 0.20, green: 0.47, blue: 0.94) + static let ocean = Color(red: 0.18, green: 0.53, blue: 0.97) + static let mint = Color(red: 0.26, green: 0.74, blue: 0.68) + static let sunrise = Color(red: 1.00, green: 0.67, blue: 0.38) + static let ink = Color(red: 0.10, green: 0.17, blue: 0.27) +} + +struct MailRootView: View { + @Bindable var model: AppViewModel + @State private var preferredCompactColumn: NavigationSplitViewColumn = .content + + var body: some View { + NavigationSplitView(preferredCompactColumn: $preferredCompactColumn) { + MailSidebarView(model: model) + } content: { + ThreadListView(model: model) + } detail: { + ThreadDetailView(model: model) + } + .navigationSplitViewStyle(.balanced) + .searchable(text: searchTextBinding, prompt: "Search mail") + .toolbar { + if showsToolbarCompose { + ToolbarItem(placement: .primaryAction) { + Button { + model.startCompose() + } label: { + Label("Compose", systemImage: "square.and.pencil") + } + } + } + } + .sheet(isPresented: $model.isComposing) { + ComposeView(model: model) + } + .task { + await model.load() + } + .task { + await model.beginBackendControl() + } + .onChange(of: model.mailboxNavigationToken) { + showCompactColumn(.content) + } + .onChange(of: model.threadNavigationToken) { + showCompactColumn(.detail) + } + .onChange(of: model.selectedThreadID) { + if model.selectedThreadID == nil { + showCompactColumn(.content) + } + } + .onChange(of: model.isComposing) { + guard model.isComposing, usesCompactSplitNavigation else { return } + model.dismissThreadSelection() + showCompactColumn(.content) + } + .onChange(of: preferredCompactColumn) { + guard usesCompactSplitNavigation, preferredCompactColumn != .detail else { return } + model.dismissThreadSelection() + } + .alert("Something went wrong", isPresented: errorPresented) { + Button("OK") { + model.errorMessage = nil + } + } message: { + Text(model.errorMessage ?? "") + } + } + + private var errorPresented: Binding { + Binding( + get: { model.errorMessage != nil }, + set: { isPresented in + if !isPresented { + model.errorMessage = nil + } + } + ) + } + + private var searchTextBinding: Binding { + Binding( + get: { model.searchText }, + set: { model.setSearchText($0) } + ) + } + + private var showsToolbarCompose: Bool { + #if os(iOS) + UIDevice.current.userInterfaceIdiom != .phone + #else + true + #endif + } + + private var usesCompactSplitNavigation: Bool { + #if os(iOS) + UIDevice.current.userInterfaceIdiom == .phone + #else + false + #endif + } + + private func showCompactColumn(_ column: NavigationSplitViewColumn) { + guard usesCompactSplitNavigation else { return } + preferredCompactColumn = column + } +} + +private struct MailSidebarView: View { + @Bindable var model: AppViewModel + + var body: some View { + List { + Section { + SidebarHeader(model: model) + .listRowInsets(EdgeInsets(top: 12, leading: 14, bottom: 16, trailing: 14)) + .listRowBackground(Color.clear) + } + + Section("Mailboxes") { + ForEach(Mailbox.allCases) { mailbox in + Button { + model.selectMailbox(mailbox) + } label: { + mailboxRow(for: mailbox) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .listRowBackground( + mailbox == model.selectedMailbox + ? MailTheme.accent.opacity(0.10) + : Color.clear + ) + .accessibilityIdentifier("sidebar.mailbox.\(mailbox.id)") + } + } + + Section("Filters") { + Toggle(isOn: unreadOnlyBinding) { + HStack { + Label("Unread Only", systemImage: "circle.badge") + Spacer() + Text(model.totalUnreadCount, format: .number) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + } + } + } + .listStyle(.sidebar) + .scrollContentBackground(.hidden) + .background(MailCanvasBackground(primary: MailTheme.ocean, secondary: MailTheme.mint)) + .navigationTitle("social.io") + } + + private var unreadOnlyBinding: Binding { + Binding( + get: { model.showUnreadOnly }, + set: { model.setUnreadOnly($0) } + ) + } + + private func mailboxRow(for mailbox: Mailbox) -> some View { + HStack(spacing: 12) { + Label(mailbox.title, systemImage: mailbox.systemImage) + .foregroundStyle(mailbox == model.selectedMailbox ? .primary : .secondary) + + Spacer() + + Text(model.threadCount(in: mailbox), format: .number) + .font(.caption.weight(.semibold)) + .foregroundStyle(mailbox == model.selectedMailbox ? .primary : .secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + mailbox == model.selectedMailbox + ? MailTheme.accent.opacity(0.14) + : Color.secondary.opacity(0.10), + in: Capsule() + ) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct SidebarHeader: View { + @Bindable var model: AppViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .center, spacing: 14) { + Image(systemName: "at.circle.fill") + .font(.system(size: 30, weight: .semibold)) + .foregroundStyle(MailTheme.accent, MailTheme.mint) + + VStack(alignment: .leading, spacing: 4) { + Text("social.io mail") + .font(.title3.weight(.bold)) + Text("Calm inboxes for real conversations.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + AdaptiveGlassGroup(spacing: 16) { + HStack(spacing: 12) { + SummaryChip( + title: "Unread", + value: model.totalUnreadCount, + tint: MailTheme.accent.opacity(0.18) + ) + + SummaryChip( + title: "Starred", + value: model.threadCount(in: .starred), + tint: MailTheme.sunrise.opacity(0.18) + ) + } + } + } + } +} + +private struct SummaryChip: View { + let title: String + let value: Int + let tint: Color? + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + Text(value, format: .number) + .font(.headline.weight(.semibold)) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .socialGlass(in: RoundedRectangle(cornerRadius: 18, style: .continuous), tint: tint) + } +} + +private struct ThreadListView: View { + @Bindable var model: AppViewModel + + var body: some View { + ZStack { + MailCanvasBackground(primary: MailTheme.ocean, secondary: MailTheme.sunrise) + .ignoresSafeArea() + + VStack(spacing: 0) { + MailboxFilterBar(model: model) + MailboxHeroCard(model: model) + + Group { + if model.isLoading { + ProgressView("Loading mail…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if model.filteredThreads.isEmpty { + ContentUnavailableView( + "No Messages", + systemImage: "tray", + description: Text("Try another mailbox or relax the filters.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(model.filteredThreads) { thread in + Button { + model.openThread(withID: thread.id) + } label: { + ThreadRow(thread: thread, isSelected: thread.id == model.selectedThreadID) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 8, leading: 18, bottom: 8, trailing: 18)) + .listRowBackground(Color.clear) + .contextMenu { + Button(thread.isUnread ? "Mark Read" : "Mark Unread") { + model.toggleRead(for: thread) + } + + Button(thread.isStarred ? "Remove Star" : "Star Thread") { + model.toggleStar(for: thread) + } + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + } + } + } + } + .safeAreaInset(edge: .bottom) { + FloatingComposeButton(model: model) + } + .navigationTitle(model.selectedMailbox.title) + .mailInlineNavigationTitle() + .mailNavigationChrome() + } +} + +private struct MailboxHeroCard: View { + @Bindable var model: AppViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(model.selectedMailbox.title) + .font(.system(.largeTitle, design: .rounded, weight: .bold)) + + Text(mailboxDescription) + .font(.subheadline) + .foregroundStyle(.secondary) + + AdaptiveGlassGroup(spacing: 12) { + HStack(spacing: 12) { + SummaryChip( + title: "Visible", + value: model.filteredThreads.count, + tint: MailTheme.accent.opacity(0.20) + ) + + SummaryChip( + title: "Unread", + value: model.filteredThreads.filter(\.isUnread).count, + tint: MailTheme.mint.opacity(0.18) + ) + + SummaryChip( + title: "Starred", + value: model.filteredThreads.filter(\.isStarred).count, + tint: MailTheme.sunrise.opacity(0.18) + ) + } + } + + if let latestThread = model.filteredThreads.first { + HStack(spacing: 8) { + Image(systemName: "clock") + Text("Latest activity \(latestThread.lastUpdated.formatted(date: .abbreviated, time: .shortened))") + } + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 22) + .frame(maxWidth: .infinity, alignment: .leading) + .background(heroBackground, in: RoundedRectangle(cornerRadius: 30, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 30, style: .continuous) + .stroke(Color.white.opacity(0.20), lineWidth: 1) + ) + .padding(.horizontal, 20) + .padding(.bottom, 12) + } + + private var mailboxDescription: String { + switch model.selectedMailbox { + case .inbox: + "Fresh conversations, live signals, and mail worth deciding on now." + case .starred: + "Pinned threads that still deserve attention, not just memory." + case .sent: + "Everything you shipped recently, ready for quick follow-up." + case .drafts: + "Half-finished notes and messages waiting for a final pass." + case .archive: + "Quieted threads with context still close at hand." + } + } + + private var heroBackground: some ShapeStyle { + LinearGradient( + colors: [ + MailTheme.accent.opacity(0.28), + MailTheme.ocean.opacity(0.16), + Color.white.opacity(0.08) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } +} + +private struct FloatingComposeButton: View { + @Bindable var model: AppViewModel + + var body: some View { + Group { + if shouldShow { + HStack { + Spacer() + + Button { + model.startCompose() + } label: { + HStack(spacing: 10) { + Image(systemName: "square.and.pencil") + Text("Compose") + } + .font(.headline.weight(.semibold)) + .padding(.horizontal, 18) + .padding(.vertical, 14) + .socialGlass( + in: Capsule(), + tint: MailTheme.accent.opacity(0.22), + interactive: true + ) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("compose.floating") + } + .padding(.horizontal, 20) + .padding(.top, 8) + .padding(.bottom, 12) + .background(Color.clear) + } + } + } + + private var shouldShow: Bool { + #if os(iOS) + UIDevice.current.userInterfaceIdiom == .phone + #else + false + #endif + } +} + +private struct MailboxFilterBar: View { + @Bindable var model: AppViewModel + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + AdaptiveGlassGroup(spacing: 16) { + HStack(spacing: 12) { + ForEach(Mailbox.allCases) { mailbox in + Button { + model.selectMailbox(mailbox) + } label: { + HStack(spacing: 8) { + Image(systemName: mailbox.systemImage) + Text(mailbox.title) + Text(model.threadCount(in: mailbox), format: .number) + .font(.caption2.weight(.bold)) + .foregroundStyle(.secondary) + } + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .socialGlass( + in: Capsule(), + tint: mailbox == model.selectedMailbox ? MailTheme.accent.opacity(0.18) : nil, + interactive: true + ) + } + .buttonStyle(.plain) + .accessibilityIdentifier("mailbox.\(mailbox.id)") + } + + Button { + model.setUnreadOnly(!model.showUnreadOnly) + } label: { + HStack(spacing: 8) { + Image(systemName: model.showUnreadOnly ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") + Text("Unread") + } + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .socialGlass( + in: Capsule(), + tint: model.showUnreadOnly ? MailTheme.mint.opacity(0.18) : nil, + interactive: true + ) + } + .buttonStyle(.plain) + .accessibilityIdentifier("filter.unread") + } + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + } + } +} + +private struct ThreadRow: View { + let thread: MailThread + let isSelected: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(thread.participants.map(\.name).joined(separator: ", ")) + .font(.subheadline.weight(thread.isUnread ? .semibold : .regular)) + .lineLimit(1) + + Text(thread.subject) + .font(.headline) + .lineLimit(1) + } + + Spacer(minLength: 0) + + VStack(alignment: .trailing, spacing: 6) { + if thread.isStarred { + Image(systemName: "star.fill") + .foregroundStyle(.yellow) + } + + Text(thread.lastUpdated.formatted(date: .abbreviated, time: .shortened)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Text(thread.previewText) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(2) + + if !thread.tags.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(thread.tags, id: \.self) { tag in + Text(tag) + .font(.caption.weight(.medium)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.secondary.opacity(0.10), in: Capsule()) + } + } + } + } + } + .padding(16) + .mailPanelBackground( + in: RoundedRectangle(cornerRadius: 24, style: .continuous), + highlight: isSelected ? MailTheme.accent.opacity(0.28) : Color.white.opacity(0.10) + ) + .accessibilityIdentifier("thread.\(thread.routeID)") + } +} + +private struct ThreadDetailView: View { + @Bindable var model: AppViewModel + + var body: some View { + ZStack { + MailCanvasBackground(primary: MailTheme.mint, secondary: MailTheme.sunrise) + .ignoresSafeArea() + + Group { + if let thread = model.selectedThread { + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 24) { + ThreadHero(threadID: thread.id, model: model) + + ForEach(thread.messages) { message in + MessageCard( + message: message, + isLatest: message.id == thread.latestMessage?.id, + isFocused: message.routeID == model.focusedMessageRouteID + ) + .id(message.routeID) + } + } + .padding(24) + .frame(maxWidth: 920, alignment: .leading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .onAppear { + scrollToFocusedMessage(using: proxy, animated: false) + } + .onChange(of: model.focusedMessageRouteID) { + scrollToFocusedMessage(using: proxy) + } + .onChange(of: thread.routeID) { + scrollToFocusedMessage(using: proxy, animated: false) + } + } + } else { + ContentUnavailableView( + "Select a Thread", + systemImage: "envelope.open", + description: Text("Choose a conversation to read or compose a new message.") + ) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .navigationTitle("Conversation") + } + + private func scrollToFocusedMessage(using proxy: ScrollViewProxy, animated: Bool = true) { + guard let focusedMessageRouteID = model.focusedMessageRouteID else { return } + + if animated { + withAnimation(.easeInOut(duration: 0.25)) { + proxy.scrollTo(focusedMessageRouteID, anchor: .center) + } + } else { + proxy.scrollTo(focusedMessageRouteID, anchor: .center) + } + } +} + +private struct ThreadHero: View { + let threadID: MailThread.ID + @Bindable var model: AppViewModel + + var body: some View { + Group { + if let thread = model.thread(withID: threadID) { + VStack(alignment: .leading, spacing: 18) { + if usesCompactHeroLayout { + VStack(alignment: .leading, spacing: 16) { + heroHeaderContent(for: thread) + ThreadActionBar(threadID: thread.id, model: model, compact: true) + } + } else { + HStack(alignment: .top, spacing: 16) { + heroHeaderContent(for: thread) + + Spacer(minLength: 0) + + ThreadActionBar(threadID: thread.id, model: model) + } + } + + if !thread.tags.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(thread.tags, id: \.self) { tag in + Text(tag) + .font(.caption.weight(.medium)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.secondary.opacity(0.10), in: Capsule()) + } + } + } + } + + Text("Latest update \(thread.lastUpdated.formatted(date: .abbreviated, time: .shortened))") + .font(.footnote) + .foregroundStyle(.secondary) + } + .padding(24) + .background(heroBackground, in: RoundedRectangle(cornerRadius: 32, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 32, style: .continuous) + .stroke(Color.white.opacity(0.18), lineWidth: 1) + ) + } + } + } + + private func heroHeaderContent(for thread: MailThread) -> some View { + VStack(alignment: .leading, spacing: 10) { + AdaptiveGlassGroup(spacing: 14) { + if usesCompactHeroLayout { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + heroStatusChips(for: thread) + } + } + } else { + HStack(spacing: 10) { + heroStatusChips(for: thread) + } + } + } + + Text(thread.subject) + .font(.system(.largeTitle, design: .rounded, weight: .bold)) + + Text(thread.participants.map(\.email).joined(separator: ", ")) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder + private func heroStatusChips(for thread: MailThread) -> some View { + StatusChip( + title: thread.mailbox.title, + systemImage: thread.mailbox.systemImage, + tint: MailTheme.accent.opacity(0.18) + ) + + StatusChip( + title: "Unread", + systemImage: "circle.badge.fill", + tint: MailTheme.sunrise.opacity(0.18) + ) + .opacity(thread.isUnread ? 1 : 0) + .accessibilityHidden(!thread.isUnread) + } + + private var heroBackground: some ShapeStyle { + LinearGradient( + colors: [ + MailTheme.accent.opacity(0.22), + MailTheme.mint.opacity(0.12), + Color.white.opacity(0.06) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + private var usesCompactHeroLayout: Bool { + #if os(iOS) + UIDevice.current.userInterfaceIdiom == .phone + #else + false + #endif + } +} + +private struct StatusChip: View { + let title: String + let systemImage: String + let tint: Color? + + var body: some View { + HStack(spacing: 8) { + Image(systemName: systemImage) + Text(title) + } + .font(.caption.weight(.semibold)) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .socialGlass(in: Capsule(), tint: tint) + } +} + +private struct ThreadActionBar: View { + let threadID: MailThread.ID + @Bindable var model: AppViewModel + var compact = false + private let controlAnimation = Animation.snappy(duration: 0.24, extraBounce: 0.03) + + var body: some View { + Group { + if let thread = model.thread(withID: threadID) { + HStack(spacing: compact ? 10 : 12) { + actionButtons(for: thread) + } + .animation(controlAnimation, value: thread.isStarred) + .animation(controlAnimation, value: thread.isUnread) + } + } + } + + private func actionButtons(for thread: MailThread) -> some View { + Group { + actionButton( + title: thread.isStarred ? "Starred" : "Star", + systemImage: thread.isStarred ? "star.fill" : "star", + tint: thread.isStarred ? MailTheme.sunrise.opacity(0.22) : nil + ) { + withAnimation(controlAnimation) { + model.toggleStar(forThreadID: thread.id) + } + } + + actionButton( + title: thread.isUnread ? "Mark Read" : "Mark Unread", + systemImage: thread.isUnread ? "envelope.open.fill" : "envelope.badge", + tint: thread.isUnread ? MailTheme.mint.opacity(0.20) : nil + ) { + withAnimation(controlAnimation) { + model.toggleRead(forThreadID: thread.id) + } + } + } + } + + private func actionButton( + title: String, + systemImage: String, + tint: Color?, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(spacing: 8) { + Image(systemName: systemImage) + .contentTransition(.symbolEffect(.replace)) + Text(title) + .lineLimit(1) + .contentTransition(.opacity) + } + .font(.subheadline.weight(.semibold)) + .fixedSize(horizontal: true, vertical: false) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .stableControlPill(tint: tint) + } + .buttonStyle(.plain) + .animation(controlAnimation, value: title) + .animation(controlAnimation, value: systemImage) + .animation(controlAnimation, value: tint != nil) + } +} + +private struct MessageCard: View { + let message: MailMessage + let isLatest: Bool + let isFocused: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text(message.sender.name) + .font(.headline) + Text(message.sender.email) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 0) + + Text(message.sentAt.formatted(date: .abbreviated, time: .shortened)) + .font(.caption) + .foregroundStyle(.secondary) + } + + Text(message.body) + .font(.body) + .textSelection(.enabled) + } + .padding(20) + .mailPanelBackground( + in: RoundedRectangle(cornerRadius: 28, style: .continuous), + highlight: messageHighlight + ) + .overlay(alignment: .topTrailing) { + if isFocused { + Text("Focused") + .font(.caption2.weight(.bold)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .socialGlass(in: Capsule(), tint: MailTheme.accent.opacity(0.18)) + .padding(14) + } + } + .accessibilityIdentifier("message.\(message.routeID)") + } + + private var messageHighlight: Color { + if isFocused { + return MailTheme.accent.opacity(0.38) + } + + if isLatest { + return MailTheme.accent.opacity(0.22) + } + + return Color.white.opacity(0.10) + } +} + +private struct ComposeView: View { + @Environment(\.dismiss) private var dismiss + @Bindable var model: AppViewModel + + var body: some View { + Group { + if usesCompactComposeLayout { + composeScene + } else { + composeScene + .frame(minWidth: 560, minHeight: 520) + } + } + .accessibilityIdentifier("compose.view") + } + + private var composeScene: some View { + NavigationStack { + ZStack { + MailCanvasBackground(primary: MailTheme.accent, secondary: MailTheme.sunrise) + .ignoresSafeArea() + + ScrollView { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 8) { + Text("New Message") + .font(.system(.largeTitle, design: .rounded, weight: .bold)) + Text("Keep the controls light and let the conversation do the work.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + ComposeFieldCard(title: "To") { + toField + } + + ComposeFieldCard(title: "Subject") { + TextField("What's this about?", text: $model.composeDraft.subject) + .textFieldStyle(.plain) + .accessibilityIdentifier("compose.subject") + } + + ComposeFieldCard(title: "Message") { + TextEditor(text: $model.composeDraft.body) + .scrollContentBackground(.hidden) + .frame(minHeight: 240) + .accessibilityIdentifier("compose.body") + } + + Spacer(minLength: 0) + } + .padding(usesCompactComposeLayout ? 20 : 24) + .frame(maxWidth: 720, alignment: .topLeading) + .frame(maxWidth: .infinity, alignment: .top) + } + } + .navigationTitle("Compose") + .navigationBarTitleDisplayMode(usesCompactComposeLayout ? .inline : .automatic) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + .accessibilityIdentifier("compose.cancel") + } + + ToolbarItem(placement: .confirmationAction) { + Button("Send") { + Task { + await model.sendCurrentDraft() + dismiss() + } + } + .disabled(model.composeDraft.to.isEmpty || model.composeDraft.body.isEmpty) + .accessibilityIdentifier("compose.send") + } + } + } + } + + @ViewBuilder + private var toField: some View { + #if os(iOS) + TextField("name@example.com", text: $model.composeDraft.to) + .textFieldStyle(.plain) + .textContentType(.emailAddress) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + .accessibilityIdentifier("compose.to") + #else + TextField("name@example.com", text: $model.composeDraft.to) + .textFieldStyle(.plain) + .textContentType(.emailAddress) + .accessibilityIdentifier("compose.to") + #endif + } + + private var usesCompactComposeLayout: Bool { + #if os(iOS) + UIDevice.current.userInterfaceIdiom == .phone + #else + false + #endif + } +} + +private struct ComposeFieldCard: View { + let title: String + let content: Content + + init(title: String, @ViewBuilder content: () -> Content) { + self.title = title + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + content + } + .padding(18) + .mailPanelBackground(in: RoundedRectangle(cornerRadius: 24, style: .continuous)) + } +} + +private struct MailCanvasBackground: View { + let primary: Color + let secondary: Color + + var body: some View { + ZStack { + LinearGradient( + colors: [ + platformBackgroundColor, + primary.opacity(0.10), + secondary.opacity(0.12) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + Circle() + .fill(primary.opacity(0.22)) + .frame(width: 360, height: 360) + .blur(radius: 90) + .offset(x: -160, y: -240) + + Circle() + .fill(secondary.opacity(0.20)) + .frame(width: 300, height: 300) + .blur(radius: 90) + .offset(x: 210, y: 260) + + Circle() + .fill(Color.white.opacity(0.10)) + .frame(width: 220, height: 220) + .blur(radius: 70) + .offset(x: 180, y: -220) + } + } +} + +private struct AdaptiveGlassGroup: View { + let spacing: CGFloat? + let content: Content + + init(spacing: CGFloat? = nil, @ViewBuilder content: () -> Content) { + self.spacing = spacing + self.content = content() + } + + var body: some View { + if #available(iOS 26.0, macOS 26.0, *) { + GlassEffectContainer(spacing: spacing) { + content + } + } else { + content + } + } +} + +private extension View { + @ViewBuilder + func socialGlass( + in shape: S, + tint: Color? = nil, + interactive: Bool = false + ) -> some View { + if #available(iOS 26.0, macOS 26.0, *) { + glassEffect( + Glass.regular.tint(tint).interactive(interactive), + in: shape + ) + } else { + background(.ultraThinMaterial, in: shape) + .overlay( + shape.stroke(Color.white.opacity(0.16), lineWidth: 1) + ) + } + } + + func mailPanelBackground( + in shape: S, + highlight: Color = Color.white.opacity(0.10) + ) -> some View { + background(.regularMaterial, in: shape) + .overlay( + shape.stroke(highlight, lineWidth: 1) + ) + } + + func stableControlPill(tint: Color?) -> some View { + background { + Capsule() + .fill(.ultraThinMaterial) + .overlay( + Capsule() + .fill(tint ?? .clear) + ) + .overlay( + Capsule() + .stroke(Color.white.opacity(0.16), lineWidth: 1) + ) + } + } + + @ViewBuilder + func mailNavigationChrome() -> some View { + #if os(iOS) + toolbarBackground(.hidden, for: .navigationBar) + #else + self + #endif + } + + @ViewBuilder + func mailInlineNavigationTitle() -> some View { + #if os(iOS) + navigationBarTitleDisplayMode(.inline) + #else + self + #endif + } +} + +private var platformBackgroundColor: Color { + #if os(macOS) + Color(nsColor: .windowBackgroundColor) + #else + Color(uiColor: .systemBackground) + #endif +}