feat: Built the native SwiftUI app shell mirroring the WebOS interface (Dashboard, Inventory, Oracle tabs)
This commit is contained in:
457
.Agent Context/gitea_contribution_guide.md
Normal file
457
.Agent Context/gitea_contribution_guide.md
Normal file
@@ -0,0 +1,457 @@
|
||||
# Project Velocity — Gitea Feature Contribution Guide
|
||||
|
||||
**Version:** 1.1
|
||||
**Last Updated:** 2026-03-07
|
||||
**Upstream Repository (Sagnik's):** `https://git.desineuron.in/sagnik/Project_Velocity`
|
||||
**Your Fork:** `https://git.desineuron.in/<YOUR_USERNAME>/Project_Velocity`
|
||||
|
||||
> This document is the **canonical guide** for every team member contributing to Project Velocity.
|
||||
> The team uses a **Fork-based workflow**: each contributor has their own fork of Sagnik's repository. All features are developed in your fork and merged into the upstream via Pull Requests.
|
||||
> It covers the full lifecycle: Issue → Branch → Commit → Push → Pull Request — both via the **CLI** and via **Antigravity Source Control UI**.
|
||||
|
||||
### How the Fork Model Works
|
||||
|
||||
```text
|
||||
[upstream] sagnik/Project_Velocity ← the source of truth
|
||||
↑ Pull Requests
|
||||
[your fork] <you>/Project_Velocity ← where you develop
|
||||
↑ git push
|
||||
[local] /your/machine/Project_Velocity
|
||||
```
|
||||
|
||||
- `upstream` = Sagnik's repo — you **pull** from here to stay in sync.
|
||||
- `origin` = Your fork — you **push** your branches here.
|
||||
- PRs go **from your fork → upstream**.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Naming Conventions](#naming-conventions)
|
||||
2. [One-Time Setup — Fork & Remotes](#one-time-setup--fork--remotes)
|
||||
3. [Step 0 — Pre-flight: Populate `.gitignore` & `.gitkeep`](#step-0--pre-flight)
|
||||
4. [Step 1 — Create a Gitea Issue](#step-1--create-a-gitea-issue)
|
||||
5. [Step 2 — Sync with Upstream Before Branching](#step-2--sync-with-upstream-before-branching)
|
||||
6. [Step 3 — Create a Feature Branch](#step-3--create-a-feature-branch)
|
||||
7. [Step 4 — Make Your Changes & Commit](#step-4--make-your-changes--commit)
|
||||
8. [Step 5 — Push the Branch to Your Fork](#step-5--push-the-branch-to-your-fork)
|
||||
9. [Step 6 — Create a Pull Request (Fork → Upstream)](#step-6--create-a-pull-request-fork--upstream)
|
||||
10. [Quick Reference — CLI Cheat Sheet](#quick-reference--cli-cheat-sheet)
|
||||
|
||||
---
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
Consistent naming keeps the repository clean and makes history easy to read.
|
||||
|
||||
| Type | Format | Example |
|
||||
|------|--------|---------|
|
||||
| **Issue Title** | `feat : <Short description>` | `feat : Build the native SwiftUI app shell` |
|
||||
| **Branch Name** | `feat/#<issue-number>` | `feat/#12` |
|
||||
| **Commit Message** | `feat(scope): <what changed>` or `fix(scope): <what was fixed>` | `feat(ios): add Dashboard, Oracle and Sentinel tab views` |
|
||||
|
||||
> Use `fix`, `chore`, `docs`, `refactor`, or `test` instead of `feat` where appropriate.
|
||||
|
||||
---
|
||||
|
||||
## One-Time Setup — Fork & Remotes
|
||||
|
||||
Do this **once** when you first clone your fork onto a new machine. You only need to do this once per machine.
|
||||
|
||||
### 1. Clone your fork
|
||||
|
||||
```bash
|
||||
git clone https://git.desineuron.in/<YOUR_USERNAME>/Project_Velocity.git
|
||||
cd Project_Velocity
|
||||
```
|
||||
|
||||
### 2. Add the upstream remote (Sagnik's repo)
|
||||
|
||||
```bash
|
||||
git remote add upstream https://git.desineuron.in/sagnik/Project_Velocity.git
|
||||
```
|
||||
|
||||
### 3. Verify your remotes
|
||||
|
||||
```bash
|
||||
git remote -v
|
||||
```
|
||||
|
||||
You should see **two** remotes:
|
||||
|
||||
```text
|
||||
origin https://git.desineuron.in/<YOUR_USERNAME>/Project_Velocity.git (fetch)
|
||||
origin https://git.desineuron.in/<YOUR_USERNAME>/Project_Velocity.git (push)
|
||||
upstream https://git.desineuron.in/sagnik/Project_Velocity.git (fetch)
|
||||
upstream https://git.desineuron.in/sagnik/Project_Velocity.git (push)
|
||||
```
|
||||
|
||||
> ⚠️ If you only see `origin`, you haven't added `upstream` yet. Run step 2 above.
|
||||
|
||||
### Via Antigravity Source Control UI
|
||||
|
||||
Antigravity does not have a direct UI button to add a new remote. Do this once via the terminal:
|
||||
1. Open a terminal inside Antigravity (`Ctrl + `` ` `` ` `).
|
||||
2. Run: `git remote add upstream https://git.desineuron.in/sagnik/Project_Velocity.git`
|
||||
3. The upstream remote will now appear in the **Graph** section of Source Control.
|
||||
|
||||
---
|
||||
|
||||
## Step 0 — Pre-flight
|
||||
|
||||
Before pushing anything, ensure these files are properly configured at the **root of the repository**.
|
||||
|
||||
### `.gitignore`
|
||||
A root `.gitignore` already exists at the repository root. It covers:
|
||||
- **macOS** system files (`DS_Store`, etc.)
|
||||
- **Node / Vite / React** (`app/node_modules/`, `app/dist/`)
|
||||
- **Xcode / Swift** (derived data, `.xcuserdata`, build artifacts)
|
||||
- **Python / ComfyUI** (`__pycache__`, `.venv`, model weights)
|
||||
- **Infrastructure** (Terraform state, secrets)
|
||||
|
||||
**You should never commit:**
|
||||
- API keys or secrets (`.env` files are ignored)
|
||||
- `node_modules/` directory
|
||||
- Xcode `DerivedData/` or `.xcuserdata/`
|
||||
- Python virtual environments (`.venv/`, `venv/`)
|
||||
|
||||
### `.gitkeep`
|
||||
A `.gitkeep` file is used to **track an otherwise empty directory** in Git (Git does not track empty folders).
|
||||
|
||||
**When to add one:** If you create a new directory that should be committed but has no files yet, add an empty `.gitkeep` file inside it:
|
||||
```bash
|
||||
touch path/to/your/new-empty-folder/.gitkeep
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Create a Gitea Issue
|
||||
|
||||
Every feature or bug fix must start with a **Gitea Issue**. Issues must be created in **Sagnik's upstream repository** — not your fork. The upstream is the source of truth for the project, and `Closes #<N>` in your PR description will only auto-close the issue if it lives in the same repo your PR targets.
|
||||
|
||||
### Via Browser (Gitea Web UI)
|
||||
|
||||
1. Go to **Sagnik's upstream repo**: `https://git.desineuron.in/sagnik/Project_Velocity`.
|
||||
2. Click on the **Issues** tab.
|
||||
3. Click the green **New Issue** button.
|
||||
4. Fill in the title using the format:
|
||||
```
|
||||
feat : <Short description of your feature>
|
||||
```
|
||||
**Example:**
|
||||
```
|
||||
feat : Build the native SwiftUI app shell mirroring the WebOS interface
|
||||
```
|
||||
5. Add a description explaining:
|
||||
- **What** you are building.
|
||||
- **Why** it is needed.
|
||||
- **Acceptance criteria** (what "done" looks like).
|
||||
6. Assign the issue to yourself.
|
||||
7. Click **Submit**. Note down the **Issue Number** (e.g., `#12`) — you will need it for the branch name.
|
||||
|
||||
### Via CLI (using `curl` or Gitea API)
|
||||
|
||||
```bash
|
||||
# Replace placeholders before running
|
||||
GITEA_URL="https://git.desineuron.in"
|
||||
GITEA_TOKEN="<YOUR_PERSONAL_ACCESS_TOKEN>" # Generate at: Profile → Settings → Applications
|
||||
REPO_OWNER="sagnik" # Issues go in the UPSTREAM repo
|
||||
REPO_NAME="Project_Velocity"
|
||||
ISSUE_TITLE="feat : <Short description of your feature>"
|
||||
ISSUE_BODY="<Detailed description of what you are building and why>"
|
||||
|
||||
curl -s -X POST "$GITEA_URL/api/v1/repos/$REPO_OWNER/$REPO_NAME/issues" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-d "{
|
||||
\"title\": \"$ISSUE_TITLE\",
|
||||
\"body\": \"$ISSUE_BODY\",
|
||||
\"assignees\": [\"$REPO_OWNER\"]
|
||||
}"
|
||||
```
|
||||
> The response JSON will contain the `number` field — **this is your issue number**.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Sync with Upstream Before Branching
|
||||
|
||||
Before creating a feature branch, **always** pull the latest changes from **`upstream`** (Sagnik's repo) — not from your fork (`origin`). This ensures your work starts from the most up-to-date version of the project.
|
||||
|
||||
### Via CLI
|
||||
|
||||
```bash
|
||||
# Navigate to your project root
|
||||
cd "/path/to/Project_Velocity"
|
||||
|
||||
# Switch to main
|
||||
git checkout main
|
||||
|
||||
# Fetch all branches from upstream
|
||||
git fetch upstream
|
||||
|
||||
# Merge upstream/main into your local main
|
||||
git merge upstream/main
|
||||
|
||||
# (Optional) Push the synced main to your own fork to keep it up to date
|
||||
git push origin main
|
||||
```
|
||||
|
||||
> **Why `upstream` and not `origin`?**
|
||||
> `origin` is your personal fork — it only knows about changes you've pushed.
|
||||
> `upstream` is Sagnik's repository — the actual source of truth for the project.
|
||||
|
||||
### Via Antigravity Source Control UI
|
||||
|
||||
Antigravity's sync button pulls from `origin` by default. To sync from upstream, use the terminal:
|
||||
|
||||
1. Open a terminal in Antigravity (`Ctrl + `` ` `` ` `).
|
||||
2. Run:
|
||||
```bash
|
||||
git fetch upstream && git merge upstream/main
|
||||
```
|
||||
3. Then click the **↺ (Refresh)** icon in the Source Control panel to refresh the view.
|
||||
4. Confirm the **Graph** section shows your local `main` is aligned with `upstream/main`.
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Create a Feature Branch
|
||||
|
||||
Branch name format: **`feat/#<ISSUE_NUMBER>`**
|
||||
|
||||
**IMPORTANT:** Replace `<ISSUE_NUMBER>` with the actual number from Step 1.
|
||||
|
||||
### Via CLI
|
||||
|
||||
```bash
|
||||
# Make sure you are on main first
|
||||
git checkout main
|
||||
|
||||
# Create and switch to the new branch
|
||||
git checkout -b feat/#<ISSUE_NUMBER>
|
||||
|
||||
# Example:
|
||||
# git checkout -b feat/#12
|
||||
```
|
||||
|
||||
### Via Antigravity Source Control UI
|
||||
|
||||
1. In the **Source Control** panel, look at the bottom **Graph** section.
|
||||
2. Click on the **branch name** shown next to the current `HEAD` commit (e.g., `main`).
|
||||
3. A branch picker will appear at the top of the screen.
|
||||
4. Type the new branch name exactly: `feat/#<ISSUE_NUMBER>`
|
||||
5. Select **"Create new branch: feat/#<ISSUE_NUMBER>"** from the dropdown.
|
||||
6. Confirm. Your working branch is now set to the new feature branch.
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Make Your Changes & Commit
|
||||
|
||||
### Stage Your Files
|
||||
|
||||
#### Via CLI
|
||||
|
||||
```bash
|
||||
# Stage specific files
|
||||
git add path/to/changed/file.swift
|
||||
|
||||
# Stage all changed/new files at once (use with care)
|
||||
git add .
|
||||
|
||||
# Review what is staged before committing
|
||||
git status
|
||||
```
|
||||
|
||||
#### Via Antigravity Source Control UI
|
||||
|
||||
1. In the **Source Control** panel, you will see two sections:
|
||||
- **Staged Changes** — files ready to be committed.
|
||||
- **Changes** — files that are modified but not yet staged.
|
||||
2. To **stage a file**: Hover over it in the **Changes** section and click the **`+`** (plus) icon that appears on the right.
|
||||
3. To **unstage a file**: Hover over it in **Staged Changes** and click the **`-`** (minus) icon.
|
||||
4. To **stage all changes at once**: Click the **`+`** (plus) icon next to the **Changes** section header.
|
||||
|
||||
### Write Your Commit Message
|
||||
|
||||
Use the **Conventional Commits** format:
|
||||
```
|
||||
<type>(scope): <short summary>
|
||||
```
|
||||
|
||||
| Type | When to Use |
|
||||
|------|-------------|
|
||||
| `feat` | A new feature |
|
||||
| `fix` | A bug fix |
|
||||
| `docs` | Documentation changes only |
|
||||
| `refactor` | Code restructured, no behavior change |
|
||||
| `chore` | Config, build scripts, dependencies |
|
||||
| `test` | Adding or fixing tests |
|
||||
|
||||
**Example commit messages:**
|
||||
```
|
||||
feat(ios): add Dashboard, Oracle, Sentinel, and Inventory tab views
|
||||
|
||||
feat(ios): implement AppStore with lead, visitor, and metrics models
|
||||
|
||||
fix(ios): correct ARSunOverlayView coordinate calculations
|
||||
|
||||
docs(agent): add velocity_ios_bible.md technical reference
|
||||
```
|
||||
|
||||
### Commit
|
||||
|
||||
#### Via CLI
|
||||
|
||||
```bash
|
||||
git commit -m "<type>(scope): <short summary of your change>"
|
||||
|
||||
# Example:
|
||||
# git commit -m "feat(ios): add Dashboard, Oracle, Sentinel tab views mirroring WebOS"
|
||||
```
|
||||
|
||||
#### Via Antigravity Source Control UI
|
||||
|
||||
1. Ensure your files are in the **Staged Changes** section.
|
||||
2. Click the **Message** text field at the top of the Source Control panel (placeholder: `Message (⌘Enter to com...)`).
|
||||
3. Type your commit message following the Conventional Commits format above.
|
||||
4. *(Optional)* Click **Generate ✦** to let Antigravity AI suggest a commit message based on your staged diffs — review it before accepting.
|
||||
5. Click the blue **✓ Commit** button (or press `⌘ + Enter`) to commit.
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Push the Branch to Gitea
|
||||
|
||||
### Via CLI
|
||||
|
||||
```bash
|
||||
# Push your feature branch to the remote (origin)
|
||||
git push origin feat/#<ISSUE_NUMBER>
|
||||
|
||||
# Example:
|
||||
# git push origin feat/#12
|
||||
```
|
||||
|
||||
If this is the **first push** of a new branch, Git may ask you to set the upstream:
|
||||
```bash
|
||||
git push --set-upstream origin feat/#<ISSUE_NUMBER>
|
||||
```
|
||||
|
||||
### Via Antigravity Source Control UI
|
||||
|
||||
1. After committing, look at the **Changes** header in the Source Control panel.
|
||||
2. Click the **`...` (More Actions)** menu (top-right of the Changes section header).
|
||||
3. Select **Push** from the dropdown.
|
||||
4. Alternatively, click the **↑ (Publish/Push)** cloud icon if visible next to the branch name in the status bar or Graph section.
|
||||
5. Antigravity will push your local feature branch to `origin/feat/#<ISSUE_NUMBER>` on Gitea.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Create a Pull Request (Fork → Upstream)
|
||||
|
||||
Since you are working on a fork, your PR goes **from your fork's feature branch → Sagnik's upstream `main`**. This is a cross-repository PR.
|
||||
|
||||
### Via Browser (Gitea Web UI)
|
||||
|
||||
1. Go to **Sagnik's upstream repo**: `https://git.desineuron.in/sagnik/Project_Velocity`.
|
||||
2. Click the **Pull Requests** tab → **New Pull Request**.
|
||||
3. Click **"compare across forks"** (Gitea shows this option when creating a cross-fork PR).
|
||||
4. Set up the PR direction:
|
||||
- **Base repository:** `sagnik/Project_Velocity` — **Base branch:** `main`
|
||||
- **Head repository:** `<YOUR_USERNAME>/Project_Velocity` — **Compare branch:** `feat/#<ISSUE_NUMBER>`
|
||||
5. Fill in the PR form:
|
||||
- **Title:** `feat(scope): <Short description>`
|
||||
- **Description:**
|
||||
```
|
||||
## Summary
|
||||
<What does this PR do?>
|
||||
|
||||
## Changes
|
||||
- <List the main changes>
|
||||
|
||||
## Related Issue
|
||||
Closes #<ISSUE_NUMBER>
|
||||
```
|
||||
6. Assign **Sagnik** as reviewer.
|
||||
7. Click **Create Pull Request**.
|
||||
|
||||
> 💡 **Alternatively**, after you push your branch to your fork (`origin`), Gitea may show a yellow banner inside Sagnik's upstream repo prompting you to create a cross-fork PR automatically. Click that if it appears.
|
||||
|
||||
### Via CLI (using Gitea API)
|
||||
|
||||
For a cross-fork PR, the `head` must include your fork username as a prefix.
|
||||
|
||||
```bash
|
||||
GITEA_URL="https://git.desineuron.in"
|
||||
GITEA_TOKEN="<YOUR_PERSONAL_ACCESS_TOKEN>"
|
||||
UPSTREAM_OWNER="sagnik" # Sagnik's username
|
||||
UPSTREAM_REPO="Project_Velocity"
|
||||
YOUR_USERNAME="<YOUR_USERNAME>" # Your Gitea username
|
||||
FEATURE_BRANCH="feat/#<ISSUE_NUMBER>"
|
||||
PR_TITLE="feat(scope): <Short description>"
|
||||
PR_BODY="## Summary\n<What does this PR do?>\n\n## Changes\n- <List main changes>\n\n## Related Issue\nCloses #<ISSUE_NUMBER>"
|
||||
|
||||
curl -s -X POST "$GITEA_URL/api/v1/repos/$UPSTREAM_OWNER/$UPSTREAM_REPO/pulls" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-d "{
|
||||
\"title\": \"$PR_TITLE\",
|
||||
\"body\": \"$PR_BODY\",
|
||||
\"head\": \"$YOUR_USERNAME:$FEATURE_BRANCH\",
|
||||
\"base\": \"main\"
|
||||
}"
|
||||
```
|
||||
|
||||
> Note the `head` format: `"<YOUR_USERNAME>:feat/#<ISSUE_NUMBER>"` — this tells Gitea the branch lives in your fork, not the upstream.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference — CLI Cheat Sheet
|
||||
|
||||
Copy and fill in the placeholders (`<...>`) before running.
|
||||
|
||||
```bash
|
||||
# ── ONE-TIME SETUP (do this once per machine) ─────────────────
|
||||
cd "/path/to/Project_Velocity"
|
||||
git remote add upstream https://git.desineuron.in/sagnik/Project_Velocity.git
|
||||
git remote -v # verify: you should see both origin and upstream
|
||||
|
||||
# ── 1. Create the Gitea Issue ──────────────────────────────────
|
||||
# Go to Gitea web UI or use the curl command in Step 1
|
||||
# Title format: feat : <Short description>
|
||||
# Note the issue number: <ISSUE_NUMBER>
|
||||
|
||||
# ── 2. Sync from upstream main ────────────────────────────────
|
||||
git checkout main
|
||||
git fetch upstream
|
||||
git merge upstream/main
|
||||
git push origin main # keep your fork in sync too
|
||||
|
||||
# ── 3. Create & switch to feature branch ─────────────────────
|
||||
git checkout -b feat/#<ISSUE_NUMBER>
|
||||
|
||||
# ── 4. Stage & Commit ─────────────────────────────────────────
|
||||
git add .
|
||||
git status # double-check what is staged
|
||||
git commit -m "<type>(scope): <short summary>"
|
||||
|
||||
# ── 5. Push branch to YOUR FORK (origin) ──────────────────────
|
||||
git push origin feat/#<ISSUE_NUMBER>
|
||||
|
||||
# ── 6. Create Pull Request (fork → upstream) ──────────────────
|
||||
# Go to: https://git.desineuron.in/sagnik/Project_Velocity
|
||||
# Pull Requests → New Pull Request → Compare across forks
|
||||
# head: <YOUR_USERNAME>/Project_Velocity : feat/#<ISSUE_NUMBER>
|
||||
# base: sagnik/Project_Velocity : main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips & Best Practices
|
||||
|
||||
- **One issue = one branch = one PR.** Keep your changes focused.
|
||||
- **Always sync from `upstream`, not `origin`.** Your fork doesn't automatically know about changes Sagnik pushes. Run `git fetch upstream && git merge upstream/main` regularly.
|
||||
- **Commit often, push when ready.** Small commits make code review easier.
|
||||
- **Never commit directly to `main`.** Always work on a feature branch.
|
||||
- **Push to `origin`, PR to `upstream`.** Push goes to your fork. The PR targets Sagnik's repo.
|
||||
- **Reference issues in PRs.** Using `Closes #<ISSUE_NUMBER>` auto-closes the issue on merge.
|
||||
- **Personal Access Token:** Generate it once at `https://git.desineuron.in` → Profile → Settings → Applications → Generate Token. Store it securely (e.g., in your password manager or macOS Keychain).
|
||||
- **Keep your fork's `main` clean.** Never push feature work directly to `origin/main` — only merge `upstream/main` into it to stay current.
|
||||
214
.Agent Context/velocity_ios_bible.md
Normal file
214
.Agent Context/velocity_ios_bible.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# Velocity iOS App - Technical Bible
|
||||
|
||||
**Document Version:** 1.0
|
||||
**Last Updated:** 2026-03-07
|
||||
**Application Version:** 1.1.0
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Executive Summary](#executive-summary)
|
||||
2. [Application Overview](#application-overview)
|
||||
3. [Technology Stack](#technology-stack)
|
||||
4. [Architecture](#architecture)
|
||||
5. [Project Structure](#project-structure)
|
||||
6. [Core Modules](#core-modules)
|
||||
7. [State Management](#state-management)
|
||||
8. [Design System](#design-system)
|
||||
9. [Development Guidelines](#development-guidelines)
|
||||
10. [API Integration Points](#api-integration-points)
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**Velocity iOS** is the native mobile counterpart of the Velocity premium real estate sales enablement platform. Built natively for Apple devices using SwiftUI, it brings the luxury property showcase and experience center ecosystems directly into the hands of sales teams on the showroom floor. The application features seamless integrations with AI-powered lead management (Oracle), biometric visitor tracking (Sentinel), and real-time inventory management.
|
||||
|
||||
**Purpose:** Provide sales teams with portable, native, intelligent insights and immersive property presentations with uncompromising performance and battery efficiency.
|
||||
|
||||
**Target Users:** Sales Directors, Real Estate Agents, Property Managers on the floor.
|
||||
|
||||
---
|
||||
|
||||
## Application Overview
|
||||
|
||||
### Key Features
|
||||
|
||||
1. **Dashboard** - Real-time metrics (Visitors, Revenue, AI Jobs, Listings), sentiment analysis gauge, system health monitoring, and a quick live AI Chat Widget.
|
||||
2. **The Oracle** - AI-powered lead management interface to handle WhatsApp, web, and walk-in leads.
|
||||
3. **The Sentinel** - Biometric visitor tracking interface, visualizing Live visitor IDs, emotion, confidence, dwell time, and zones.
|
||||
4. **Inventory** - Real-time unit availability tracking, including augmented reality overlays (`ARSunOverlayView`).
|
||||
5. **Settings** - App configurations and connectivity testing.
|
||||
|
||||
### User Flow
|
||||
|
||||
```
|
||||
Launch (VelocityApp) -> ContentView (Entry Hub) -> Feature Tabs/Navigation -> Native Views (Dashboard, Oracle, Sentinel, etc.)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technology Stack
|
||||
|
||||
### Core Frameworks
|
||||
- **Swift** - Primary programming language.
|
||||
- **SwiftUI** - Declarative UI framework for building the entire interface.
|
||||
- **Combine / Observation** (`@Observable`) - Reactive programming for robust state management.
|
||||
|
||||
### Networking & Data
|
||||
- **Alamofire** (via `ComfyClient.swift`) - Elegant HTTP networking for handling requests (e.g. Dream Weaver AI image generation).
|
||||
|
||||
### UI & Animations
|
||||
- **SwiftUI Animation** - Native transitions (`.easeInOut`, `.spring()`, `.numericText()`).
|
||||
- **Glassmorphism natively** - via custom modifiers and blurs (`GlassCard`, `GlassBlurView`).
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Application Architecture
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ VelocityApp.swift │
|
||||
│ (App Entry & WindowGroup Setup) │
|
||||
└─────────────────────────────────┬───────────────────────────┘
|
||||
│
|
||||
┌─────────────────────────────────┴───────────────────────────┐
|
||||
│ ContentView.swift │
|
||||
│ (Main App Structure / Tab View) │
|
||||
└──────┬────────────────────┬──────────────────────┬──────────┘
|
||||
│ │ │
|
||||
┌──────┴──────┐ ┌──────┴──────┐ ┌──────┴──────┐
|
||||
│ Dashboard │ │ Oracle │ │ Sentinel │
|
||||
│ Module │ │ Module │ │ Module │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
### State Architecture
|
||||
A centralized `AppStore` leverages the `@Observable` macro to power the state of the app across different modules asynchronously.
|
||||
|
||||
```text
|
||||
┌────────────────────────────────────────────────┐
|
||||
│ AppStore.swift (@Observable) │
|
||||
├────────────────────────────────────────────────┤
|
||||
│ Metrics (DashboardMetrics) │
|
||||
│ Visitors (Sentinel array) │
|
||||
│ Alerts (isAlertActive, alertMessage) │
|
||||
│ Leads & Messages (Oracle data) │
|
||||
└──────────────────────┬─────────────────────────┘
|
||||
│
|
||||
┌─────────┴──────────┐
|
||||
│ UI Subscriptions │
|
||||
│ (Environment/State)│
|
||||
└────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Directory Layout
|
||||
|
||||
```text
|
||||
iOS/
|
||||
├── App/ # App Lifecycle
|
||||
│ ├── ContentView.swift # Main App Tab/Navigation Hub
|
||||
│ └── VelocityApp.swift # @main Entry Point
|
||||
├── Core/ # Shared infrastructure
|
||||
│ ├── Math/
|
||||
│ │ └── SunMath.swift # Calculation utilities for AR/Environment
|
||||
│ ├── Networking/
|
||||
│ │ └── ComfyClient.swift# Alamofire client (AI integrations)
|
||||
│ ├── State/
|
||||
│ │ └── AppStore.swift # Single source of truth state manager
|
||||
│ └── UI/
|
||||
│ │ ├── GlassBlurView.swift # Native blur implementations
|
||||
│ │ └── VelocityTheme.swift # Design system tokens
|
||||
└── Features/ # Feature-specific modules
|
||||
├── Dashboard/
|
||||
│ └── DashboardView.swift # Analytics, KPIs, Sentiment Gauge
|
||||
├── Inventory/
|
||||
│ ├── ARSunOverlayView.swift # AR overlay for property sun paths
|
||||
│ └── InventoryView.swift
|
||||
├── Oracle/
|
||||
│ └── OracleView.swift # Lead progression and AI chat interface
|
||||
├── Sentinel/
|
||||
│ └── SentinelView.swift # Visitor facial & emotion analysis
|
||||
└── Settings/
|
||||
└── SettingsView.swift # App configuration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Core Modules
|
||||
|
||||
### 1. Dashboard (`DashboardView.swift`)
|
||||
**Purpose:** Executive overview matching the WebOS counterpart with native fluidity.
|
||||
**Components:**
|
||||
- **LiveKPICard**: Displays `Visitors`, `Revenue`, `AI Jobs`, and `Listings` with pulse animations.
|
||||
- **Sentiment Gauge**: A dynamic, animated thermometer evaluating the overall showroom vibe.
|
||||
- **System Health Panel**: Monitors underlying CPU/GPU/Memory configurations.
|
||||
- **AI Chat Widget**: Embedded, fast-access AI query terminal with typing indicators and conversational memory.
|
||||
|
||||
### 2. Apps & AI Integration (`ComfyClient.swift` & Oracles)
|
||||
The iOS app natively implements calls to standard HTTP backends and advanced AI generation services using an `Alamofire` `Session` directly connecting to high-GPU instances for rapid image synthesis (`DreamWeaverRequest`).
|
||||
|
||||
---
|
||||
|
||||
## State Management
|
||||
|
||||
### Zustand to AppStore Mapping
|
||||
The iOS app natively replicates the WebOS `Zustand` state mechanism heavily through `AppStore.swift`.
|
||||
|
||||
- **Live Ticker:** Contains a background publisher running every 5 seconds that jitter-updates visitors, sentiment, and raises mock backend alerts.
|
||||
- **Models:**
|
||||
- `Visitor`: Contains `id`, `faceId`, `sentiment`, `confidence`, `zone`.
|
||||
- `Lead`: Categorizes lead paths (Walk-in, Website, WhatsApp), Qualification (Whale, Potential), Budget.
|
||||
- `ChatMessage` & `DashboardMetrics`: Shared data schemas for AI interactions and KPIs.
|
||||
|
||||
---
|
||||
|
||||
## Design System
|
||||
|
||||
**Location:** `Core/UI/VelocityTheme.swift`
|
||||
|
||||
### Color Palette
|
||||
Precisely mirrors the web dashboard's high-contrast, premium dark mode:
|
||||
- **Backgrounds**: True black `(0, 0, 0)` with elevated surfaces (`#131418`, `#22262e`).
|
||||
- **Foregrounds**: Off-white for max readability, subtle grays for muted elements.
|
||||
- **Accent**: `VelocityTheme.accent` `(0.231, 0.510, 0.965)` (Blue)
|
||||
- **Semantic Status**: Matches SwiftUI `Color` blocks (`success`, `warning`, `danger$).
|
||||
|
||||
### Native Glassmorphism
|
||||
Using native `VisualEffectView` or styled semi-transparent blocks wrapped in `.modifier(GlassCard())`.
|
||||
```swift
|
||||
// Implementation
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071).opacity(0.82))
|
||||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(VelocityTheme.borderAccent, lineWidth: 1))
|
||||
.shadow(color: .black.opacity(0.55), radius: 16, y: 4)
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
**UI Paradigms:**
|
||||
- Leverage `ZStack` and `GeometryReader` for scale-dependent UI components like gauges and progress bars.
|
||||
- Add subtle animations using `.animation(.easeInOut(duration: 0.4), value: ...)` to make data updates feel organic.
|
||||
- Restrict logic calculations out of the `View` body, moving them to extensions, external math engines (`SunMath.swift`), or the `AppStore`.
|
||||
|
||||
---
|
||||
|
||||
## API Integration Points
|
||||
|
||||
**ComfyClient / Dream Weaver:**
|
||||
- Core generation client utilizing `.post` to `http://192.168.x.x:8000/dream-weaver`.
|
||||
- Automates formatting to Base64 formats for sending payloads to AI models and decoding the respective `output_base64` image back to a `UIImage`.
|
||||
|
||||
*(Note: Data sources like leads and visitors are currently heavily maintained via the native `AppStore` state but conform to general structures mapping to `/api/leads` and `/api/visitors` on the production backends).*
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# This file exists to track the `.Team Context` directory in Git.
|
||||
# Git does not track empty directories. Once this folder contains
|
||||
# team-facing documents (briefs, transcripts, personas), this file
|
||||
# should be replaced or removed.
|
||||
#
|
||||
# Do NOT commit sensitive credentials or personal data here.
|
||||
|
||||
161
.gitignore
vendored
Normal file
161
.gitignore
vendored
Normal file
@@ -0,0 +1,161 @@
|
||||
# ================================================
|
||||
# Project Velocity - Root .gitignore
|
||||
# Covers: Vite/React (app/), SwiftUI/Xcode (iOS/),
|
||||
# Python (comfy_engine/), Infra, IDE, OS
|
||||
# ================================================
|
||||
|
||||
# ────────────────────────────────────────────────
|
||||
# OS & System
|
||||
# ────────────────────────────────────────────────
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# ────────────────────────────────────────────────
|
||||
# IDE & Editor
|
||||
# ────────────────────────────────────────────────
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.cursor/
|
||||
.gemini/
|
||||
|
||||
# ────────────────────────────────────────────────
|
||||
# Environment & Secrets
|
||||
# ────────────────────────────────────────────────
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.development
|
||||
.env.production
|
||||
*.pem
|
||||
*.key
|
||||
*.cert
|
||||
secrets.json
|
||||
|
||||
# ────────────────────────────────────────────────
|
||||
# React / Vite / Node (app/)
|
||||
# ────────────────────────────────────────────────
|
||||
app/node_modules/
|
||||
app/dist/
|
||||
app/.vite/
|
||||
app/coverage/
|
||||
app/.cache/
|
||||
|
||||
# NPM / Yarn / PNPM
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
.pnpm-store/
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Optional: TypeScript build info
|
||||
app/tsconfig.tsbuildinfo
|
||||
*.tsbuildinfo
|
||||
|
||||
# ────────────────────────────────────────────────
|
||||
# Xcode / Swift / iOS (iOS/)
|
||||
# ────────────────────────────────────────────────
|
||||
|
||||
# Build output
|
||||
iOS/**/build/
|
||||
iOS/**/*.xcarchive
|
||||
iOS/**/*.ipa
|
||||
iOS/**/*.dSYM.zip
|
||||
iOS/**/*.dSYM/
|
||||
|
||||
# Xcode derived data & caches
|
||||
iOS/**/DerivedData/
|
||||
iOS/**/*.pbxuser
|
||||
!default.pbxuser
|
||||
iOS/**/*.mode1v3
|
||||
!default.mode1v3
|
||||
iOS/**/*.mode2v3
|
||||
!default.mode2v3
|
||||
iOS/**/*.perspectivev3
|
||||
!default.perspectivev3
|
||||
iOS/**/*.xcworkspace/xcuserdata/
|
||||
iOS/**/*.xcodeproj/xcuserdata/
|
||||
iOS/**/xcshareddata/xctestrun/
|
||||
|
||||
# Package.resolved (Swift Package Manager – keep if you want reproducible builds)
|
||||
# iOS/**/*.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
|
||||
|
||||
# CocoaPods (if used)
|
||||
iOS/**/Pods/
|
||||
iOS/**/Podfile.lock
|
||||
|
||||
# Swift Package Manager build artifacts
|
||||
iOS/**/.build/
|
||||
iOS/**/.swiftpm/
|
||||
|
||||
# Playgrounds
|
||||
iOS/**/*.playground/timeline.xctimeline
|
||||
|
||||
# Simulator logs
|
||||
iOS/**/xcuserstated/
|
||||
|
||||
# ────────────────────────────────────────────────
|
||||
# Python / ComfyUI Engine (comfy_engine/)
|
||||
# ────────────────────────────────────────────────
|
||||
comfy_engine/__pycache__/
|
||||
comfy_engine/**/__pycache__/
|
||||
comfy_engine/**/*.pyc
|
||||
comfy_engine/**/*.pyo
|
||||
comfy_engine/**/*.pyd
|
||||
comfy_engine/.venv/
|
||||
comfy_engine/venv/
|
||||
comfy_engine/env/
|
||||
comfy_engine/*.egg-info/
|
||||
comfy_engine/dist/
|
||||
comfy_engine/build/
|
||||
comfy_engine/.pytest_cache/
|
||||
comfy_engine/.mypy_cache/
|
||||
comfy_engine/.ruff_cache/
|
||||
|
||||
# ComfyUI specific outputs
|
||||
comfy_engine/output/
|
||||
comfy_engine/input/
|
||||
comfy_engine/models/
|
||||
comfy_engine/custom_nodes/*/node_modules/
|
||||
|
||||
# ────────────────────────────────────────────────
|
||||
# Infrastructure / Deployment
|
||||
# ────────────────────────────────────────────────
|
||||
infrastructure/**/*.tfstate
|
||||
infrastructure/**/*.tfstate.backup
|
||||
infrastructure/**/.terraform/
|
||||
infrastructure/**/.terraform.lock.hcl
|
||||
infrastructure/**/terraform.tfvars
|
||||
*.tfvars
|
||||
|
||||
# Docker
|
||||
*.log
|
||||
docker-compose.override.yml
|
||||
|
||||
# ────────────────────────────────────────────────
|
||||
# Agent / AI Context (do NOT commit sensitive internals)
|
||||
# ────────────────────────────────────────────────
|
||||
.Agent Context/transcript.json
|
||||
.Agent Context/transcript.pdf
|
||||
.Agent Context/*.docx
|
||||
|
||||
# ────────────────────────────────────────────────
|
||||
# Misc
|
||||
# ────────────────────────────────────────────────
|
||||
*.tmp
|
||||
*.bak
|
||||
*.orig
|
||||
*.log
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
@@ -1,19 +1,30 @@
|
||||
import SwiftUI
|
||||
|
||||
enum AppSection: String, CaseIterable, Hashable {
|
||||
enum AppSection: String, CaseIterable, Hashable, Identifiable {
|
||||
var id: String { rawValue }
|
||||
case dashboard = "Dashboard"
|
||||
case oracle = "Oracle"
|
||||
case sentinel = "Sentinel"
|
||||
case oracle = "Oracle"
|
||||
case sentinel = "Sentinel"
|
||||
case inventory = "Inventory"
|
||||
case settings = "Settings"
|
||||
case settings = "Settings"
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .dashboard: return "square.grid.2x2"
|
||||
case .oracle: return "message"
|
||||
case .sentinel: return "person.crop.rectangle"
|
||||
case .oracle: return "message.and.waveform"
|
||||
case .sentinel: return "person.crop.rectangle"
|
||||
case .inventory: return "shippingbox"
|
||||
case .settings: return "gearshape"
|
||||
case .settings: return "gearshape"
|
||||
}
|
||||
}
|
||||
|
||||
var accentColor: Color {
|
||||
switch self {
|
||||
case .dashboard: return VelocityTheme.accent
|
||||
case .oracle: return Color(red: 0.13, green: 0.83, blue: 0.93) // cyan
|
||||
case .sentinel: return Color(red: 0.60, green: 0.57, blue: 0.99) // indigo
|
||||
case .inventory: return VelocityTheme.warning
|
||||
case .settings: return VelocityTheme.mutedFg
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,29 +33,141 @@ struct ContentView: View {
|
||||
@State private var selectedSection: AppSection? = .dashboard
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List(AppSection.allCases, selection: $selectedSection) { section in
|
||||
Label(section.rawValue, systemImage: section.systemImage)
|
||||
.tag(section)
|
||||
}
|
||||
.navigationTitle("Velocity")
|
||||
NavigationSplitView(columnVisibility: .constant(.all)) {
|
||||
sidebarContent
|
||||
} detail: {
|
||||
detailContent
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
}
|
||||
|
||||
// MARK: – Sidebar
|
||||
private var sidebarContent: some View {
|
||||
ZStack {
|
||||
VelocityTheme.sidebarBg.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// App title
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 9)
|
||||
.fill(VelocityTheme.accent.opacity(0.18))
|
||||
.frame(width: 34, height: 34)
|
||||
Image(systemName: "bolt.fill")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("Velocity")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Project Velocity · v1.1")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
Divider()
|
||||
.background(VelocityTheme.borderSubtle)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
// Nav items
|
||||
VStack(spacing: 2) {
|
||||
ForEach(AppSection.allCases) { section in
|
||||
SidebarRow(section: section,
|
||||
isSelected: selectedSection == section)
|
||||
.onTapGesture { selectedSection = section }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
|
||||
Spacer()
|
||||
|
||||
// User footer
|
||||
Divider()
|
||||
.background(VelocityTheme.borderSubtle)
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(VelocityTheme.accent)
|
||||
.frame(width: 32, height: 32)
|
||||
Text("AF")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Ahmed Al-Farsi")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Sales Director")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
.navigationTitle("")
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
}
|
||||
|
||||
// MARK: – Detail
|
||||
private var detailContent: some View {
|
||||
ZStack {
|
||||
VelocityTheme.background.ignoresSafeArea()
|
||||
|
||||
Group {
|
||||
switch selectedSection {
|
||||
case .dashboard: DashboardView()
|
||||
case .oracle: OracleView()
|
||||
case .sentinel: SentinelView()
|
||||
case .oracle: OracleView()
|
||||
case .sentinel: SentinelView()
|
||||
case .inventory: InventoryView()
|
||||
case .settings: SettingsView()
|
||||
case .none: DashboardView()
|
||||
case .settings: SettingsView()
|
||||
case .none: DashboardView()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(uiColor: .systemGroupedBackground))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Sidebar Row
|
||||
private struct SidebarRow: View {
|
||||
let section: AppSection
|
||||
let isSelected: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 11) {
|
||||
Image(systemName: section.systemImage)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(isSelected ? section.accentColor : VelocityTheme.mutedFg)
|
||||
.frame(width: 20)
|
||||
|
||||
Text(section.rawValue)
|
||||
.font(.system(size: 14, weight: isSelected ? .semibold : .regular))
|
||||
.foregroundStyle(isSelected ? VelocityTheme.foreground : VelocityTheme.mutedFg)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 9)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(isSelected ? section.accentColor.opacity(0.12) : .clear)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(isSelected ? section.accentColor.opacity(0.25) : .clear, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ struct VelocityApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Alamofire
|
||||
@preconcurrency import Alamofire
|
||||
|
||||
final class ComfyClient {
|
||||
static let shared = ComfyClient()
|
||||
@@ -45,12 +45,12 @@ final class ComfyClient {
|
||||
}
|
||||
}
|
||||
|
||||
private struct DreamWeaverRequest: Encodable {
|
||||
private struct DreamWeaverRequest: Encodable, Sendable {
|
||||
let imageBase64: String
|
||||
let prompt: String
|
||||
}
|
||||
|
||||
private struct DreamWeaverResponse: Decodable {
|
||||
private struct DreamWeaverResponse: Decodable, Sendable {
|
||||
let outputBase64: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
|
||||
256
iOS/Core/State/AppStore.swift
Normal file
256
iOS/Core/State/AppStore.swift
Normal file
@@ -0,0 +1,256 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
// MARK: – Data Models
|
||||
|
||||
enum SentimentType: String, CaseIterable {
|
||||
case excited, interested, neutral, confused, disinterested
|
||||
var score: Int {
|
||||
switch self {
|
||||
case .excited: return 100
|
||||
case .interested: return 80
|
||||
case .neutral: return 50
|
||||
case .confused: return 30
|
||||
case .disinterested: return 10
|
||||
}
|
||||
}
|
||||
var emoji: String {
|
||||
switch self {
|
||||
case .excited: return "😃"
|
||||
case .interested: return "🤔"
|
||||
case .neutral: return "😐"
|
||||
case .confused: return "😕"
|
||||
case .disinterested: return "😴"
|
||||
}
|
||||
}
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .excited: return VelocityTheme.success
|
||||
case .interested: return VelocityTheme.accent
|
||||
case .neutral: return VelocityTheme.mutedFg
|
||||
case .confused: return VelocityTheme.warning
|
||||
case .disinterested: return VelocityTheme.danger
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Visitor: Identifiable {
|
||||
let id: String
|
||||
let faceId: String
|
||||
var sentiment: SentimentType
|
||||
var confidence: Double
|
||||
var dwellTime: Int // seconds
|
||||
var zone: String
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
enum LeadSource: String {
|
||||
case whatsapp = "WhatsApp"
|
||||
case walkin = "Walk-in"
|
||||
case website = "Website"
|
||||
}
|
||||
|
||||
enum LeadStatus: String {
|
||||
case hot = "Hot"
|
||||
case engaged = "Engaged"
|
||||
case new = "New"
|
||||
case qualified = "Qualified"
|
||||
case closed = "Closed"
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .hot: return VelocityTheme.danger
|
||||
case .engaged: return VelocityTheme.accent
|
||||
case .new: return VelocityTheme.mutedFg
|
||||
case .qualified: return VelocityTheme.success
|
||||
case .closed: return Color(red: 0.60, green: 0.57, blue: 0.99)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Lead: Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
let phone: String
|
||||
let source: LeadSource
|
||||
var status: LeadStatus
|
||||
var lastMessage: String
|
||||
var lastActive: Date
|
||||
var unreadCount: Int
|
||||
let qualification: String
|
||||
let budget: String
|
||||
let interest: String
|
||||
var initials: String { String(name.split(separator: " ").prefix(2).compactMap(\.first)) }
|
||||
}
|
||||
|
||||
struct ChatMessage: Identifiable {
|
||||
let id: String
|
||||
let sender: String // "user" | "oracle" | "ai"
|
||||
let content: String
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
struct SystemHealth {
|
||||
var cpu: Double // 0–1
|
||||
var gpu: Double
|
||||
var memory: Double
|
||||
}
|
||||
|
||||
struct DashboardMetrics {
|
||||
var activeVisitors: Int
|
||||
var revenue: String
|
||||
var aiJobs: Int
|
||||
var dailyVisitors: Int
|
||||
var sentimentScore: Double // 0–100
|
||||
var systemHealth: SystemHealth
|
||||
}
|
||||
|
||||
// MARK: – Shared Store
|
||||
|
||||
@Observable
|
||||
final class AppStore {
|
||||
|
||||
static let shared = AppStore()
|
||||
private init() { startTimer() }
|
||||
|
||||
// ── Dashboard ─────────────────────────────────────────────────
|
||||
var metrics = DashboardMetrics(
|
||||
activeVisitors: 17,
|
||||
revenue: "$3.2M",
|
||||
aiJobs: 24,
|
||||
dailyVisitors: 128,
|
||||
sentimentScore: 78,
|
||||
systemHealth: SystemHealth(cpu: 0.42, gpu: 0.61, memory: 0.55)
|
||||
)
|
||||
|
||||
var dashboardMessages: [ChatMessage] = [
|
||||
ChatMessage(id: "d0", sender: "ai",
|
||||
content: "Hello, Ahmed. I've analysed the Q3 pipeline. Would you like a refined strategy for the Apex Innovations deal?",
|
||||
timestamp: Date().addingTimeInterval(-300))
|
||||
]
|
||||
var isDashboardThinking = false
|
||||
|
||||
// ── Visitors ──────────────────────────────────────────────────
|
||||
var visitors: [Visitor] = [
|
||||
Visitor(id: "v1", faceId: "face_001", sentiment: .excited, confidence: 0.92, dwellTime: 450, zone: "Penthouse Show", timestamp: Date()),
|
||||
Visitor(id: "v2", faceId: "face_002", sentiment: .interested, confidence: 0.87, dwellTime: 320, zone: "Amenity Deck VR", timestamp: Date()),
|
||||
Visitor(id: "v3", faceId: "face_003", sentiment: .neutral, confidence: 0.78, dwellTime: 180, zone: "Reception", timestamp: Date()),
|
||||
Visitor(id: "v4", faceId: "face_004", sentiment: .confused, confidence: 0.74, dwellTime: 95, zone: "Penthouse Show", timestamp: Date()),
|
||||
Visitor(id: "v5", faceId: "face_005", sentiment: .disinterested, confidence: 0.65, dwellTime: 60, zone: "Gallery", timestamp: Date()),
|
||||
]
|
||||
|
||||
// ── Alerts ────────────────────────────────────────────────────
|
||||
var isAlertActive = false
|
||||
var alertMessage = ""
|
||||
|
||||
func triggerAlert(_ msg: String) {
|
||||
isAlertActive = true
|
||||
alertMessage = msg
|
||||
}
|
||||
func clearAlert() {
|
||||
isAlertActive = false
|
||||
alertMessage = ""
|
||||
}
|
||||
|
||||
// ── Leads (Oracle) ────────────────────────────────────────────
|
||||
var leads: [Lead] = [
|
||||
Lead(id: "1", name: "Mohammed Al-Rashid", phone: "+971 55 123 4567", source: .whatsapp,
|
||||
status: .hot, lastMessage: "Can we schedule a viewing for the penthouse tomorrow?",
|
||||
lastActive: Date().addingTimeInterval(-300), unreadCount: 2,
|
||||
qualification: "whale", budget: "AED 15M+", interest: "Penthouse Suite"),
|
||||
Lead(id: "2", name: "Sarah Chen", phone: "+971 50 987 6543", source: .walkin,
|
||||
status: .engaged, lastMessage: "Thank you for the brochure. I will review with my partner.",
|
||||
lastActive: Date().addingTimeInterval(-1800), unreadCount: 0,
|
||||
qualification: "potential", budget: "AED 5–8M", interest: "2BR Sea View"),
|
||||
Lead(id: "3", name: "James Wilson", phone: "+971 52 456 7890", source: .website,
|
||||
status: .new, lastMessage: "Interested in investment opportunities.",
|
||||
lastActive: Date().addingTimeInterval(-7200), unreadCount: 1,
|
||||
qualification: "potential", budget: "AED 3–5M", interest: "1BR Investment"),
|
||||
Lead(id: "4", name: "Fatima Hassan", phone: "+971 54 321 0987", source: .whatsapp,
|
||||
status: .qualified,lastMessage: "What are the payment plan options?",
|
||||
lastActive: Date().addingTimeInterval(-14400), unreadCount: 0,
|
||||
qualification: "whale", budget: "AED 12M+", interest: "3BR + Maid"),
|
||||
Lead(id: "5", name: "David Kumar", phone: "+971 56 789 0123", source: .walkin,
|
||||
status: .closed, lastMessage: "Contract signed. Thank you!",
|
||||
lastActive: Date().addingTimeInterval(-86400), unreadCount: 0,
|
||||
qualification: "whale", budget: "AED 20M", interest: "Full Floor"),
|
||||
]
|
||||
|
||||
var messages: [String: [ChatMessage]] = [
|
||||
"1": [
|
||||
ChatMessage(id: "m1", sender: "user", content: "Hi, I am interested in the penthouse units.",
|
||||
timestamp: Date().addingTimeInterval(-7200)),
|
||||
ChatMessage(id: "m2", sender: "oracle",
|
||||
content: "Welcome! Our penthouse collection features 4 exclusive units with panoramic sea views. Prices start at AED 15M.",
|
||||
timestamp: Date().addingTimeInterval(-7200 + 30)),
|
||||
ChatMessage(id: "m3", sender: "user", content: "Can we schedule a viewing tomorrow?",
|
||||
timestamp: Date().addingTimeInterval(-300)),
|
||||
],
|
||||
"2": [
|
||||
ChatMessage(id: "m4", sender: "oracle",
|
||||
content: "Hello Sarah! Here is the digital brochure for the 2-bedroom units we discussed.",
|
||||
timestamp: Date().addingTimeInterval(-14400)),
|
||||
ChatMessage(id: "m5", sender: "user", content: "Thank you. I will review with my partner.",
|
||||
timestamp: Date().addingTimeInterval(-1800)),
|
||||
],
|
||||
]
|
||||
|
||||
var activeLeadId: String? = "1"
|
||||
var isOracleThinking = false
|
||||
|
||||
func addDashboardMessage(sender: String, content: String) {
|
||||
let msg = ChatMessage(id: UUID().uuidString, sender: sender, content: content, timestamp: Date())
|
||||
dashboardMessages.append(msg)
|
||||
}
|
||||
|
||||
func addOracleMessage(leadId: String, sender: String, content: String) {
|
||||
let msg = ChatMessage(id: UUID().uuidString, sender: sender, content: content, timestamp: Date())
|
||||
if messages[leadId] == nil { messages[leadId] = [] }
|
||||
messages[leadId]!.append(msg)
|
||||
}
|
||||
|
||||
// ── Live ticker ───────────────────────────────────────────────
|
||||
private var timerTask: AnyCancellable?
|
||||
private var alertTask: DispatchWorkItem?
|
||||
|
||||
private func startTimer() {
|
||||
timerTask = Timer.publish(every: 5, on: .main, in: .common)
|
||||
.autoconnect()
|
||||
.sink { [weak self] _ in self?.tick() }
|
||||
}
|
||||
|
||||
private func tick() {
|
||||
// jitter visitor count ±1
|
||||
let delta = Int.random(in: -1...1)
|
||||
metrics.activeVisitors = max(10, metrics.activeVisitors + delta)
|
||||
|
||||
// jitter sentiment ±2
|
||||
let sDelta = Double.random(in: -2...2)
|
||||
metrics.sentimentScore = min(100, max(40, metrics.sentimentScore + sDelta))
|
||||
|
||||
// jitter system health
|
||||
metrics.systemHealth.cpu = Double.random(in: 0.30...0.65)
|
||||
metrics.systemHealth.gpu = Double.random(in: 0.45...0.75)
|
||||
metrics.systemHealth.memory = Double.random(in: 0.40...0.70)
|
||||
|
||||
// Random alert (same 10% chance as WebOS every tick)
|
||||
if !isAlertActive && Double.random(in: 0...1) > 0.85 {
|
||||
triggerAlert("Confusion detected in Zone B – Penthouse Gallery")
|
||||
let work = DispatchWorkItem { [weak self] in self?.clearAlert() }
|
||||
alertTask = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: work)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Helpers
|
||||
|
||||
extension Date {
|
||||
var relativeShort: String {
|
||||
let diff = Int(Date().timeIntervalSince(self))
|
||||
if diff < 60 { return "now" }
|
||||
if diff < 3600 { return "\(diff / 60)m ago" }
|
||||
if diff < 86400 { return "\(diff / 3600)h ago" }
|
||||
return "\(diff / 86400)d ago"
|
||||
}
|
||||
}
|
||||
60
iOS/Core/UI/VelocityTheme.swift
Normal file
60
iOS/Core/UI/VelocityTheme.swift
Normal file
@@ -0,0 +1,60 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Design Tokens matching the WebOS dark interface
|
||||
enum VelocityTheme {
|
||||
|
||||
// ── Backgrounds ──────────────────────────────────
|
||||
/// True black app background
|
||||
static let background = Color(red: 0.00, green: 0.00, blue: 0.00)
|
||||
/// Dark surface (#131418)
|
||||
static let surface = Color(red: 0.074, green: 0.078, blue: 0.094)
|
||||
/// Slightly lighter surface (#181b20)
|
||||
static let surface2 = Color(red: 0.095, green: 0.106, blue: 0.125)
|
||||
/// Card surface (#22262e)
|
||||
static let surface3 = Color(red: 0.133, green: 0.149, blue: 0.180)
|
||||
/// Sidebar background (#0B0D10)
|
||||
static let sidebarBg = Color(red: 0.043, green: 0.051, blue: 0.063)
|
||||
|
||||
// ── Foreground ────────────────────────────────────
|
||||
static let foreground = Color(white: 0.96)
|
||||
static let mutedFg = Color(red: 0.580, green: 0.620, blue: 0.710)
|
||||
static let subtleFg = Color(red: 0.35, green: 0.38, blue: 0.44)
|
||||
|
||||
// ── Accent: Blue (#3b82f6) ────────────────────────
|
||||
static let accent = Color(red: 0.231, green: 0.510, blue: 0.965)
|
||||
static let accentDim = Color(red: 0.160, green: 0.388, blue: 0.820)
|
||||
static let accentSubtle = Color(red: 0.231, green: 0.510, blue: 0.965).opacity(0.15)
|
||||
|
||||
// ── Semantic ──────────────────────────────────────
|
||||
static let success = Color(red: 0.290, green: 0.780, blue: 0.290)
|
||||
static let warning = Color(red: 0.980, green: 0.745, blue: 0.141)
|
||||
static let danger = Color(red: 0.973, green: 0.267, blue: 0.267)
|
||||
|
||||
// ── Borders ───────────────────────────────────────
|
||||
static let borderSubtle = Color.white.opacity(0.07)
|
||||
static let borderAccent = Color(red: 0.231, green: 0.510, blue: 0.965).opacity(0.18)
|
||||
}
|
||||
|
||||
// MARK: - Glass card modifier
|
||||
struct GlassCard: ViewModifier {
|
||||
var cornerRadius: CGFloat = 16
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071).opacity(0.82))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.stroke(VelocityTheme.borderAccent, lineWidth: 1)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.55), radius: 16, y: 4)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func glassCard(cornerRadius: CGFloat = 16) -> some View {
|
||||
self.modifier(GlassCard(cornerRadius: cornerRadius))
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,442 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DashboardView: View {
|
||||
private let columns = [GridItem(.adaptive(minimum: 220), spacing: 16)]
|
||||
private var store: AppStore { AppStore.shared }
|
||||
@State private var chatInput = ""
|
||||
private let columns = [GridItem(.adaptive(minimum: 220), spacing: 14)]
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 16) {
|
||||
WidgetCard(title: "Listings", value: "128", subtitle: "Active units")
|
||||
WidgetCard(title: "Revenue", value: "$3.2M", subtitle: "30-day forecast")
|
||||
WidgetCard(title: "AI Jobs", value: "24", subtitle: "Queue depth")
|
||||
WidgetCard(title: "Visitors", value: "17", subtitle: "Today")
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
pageHeader
|
||||
|
||||
// KPI Grid — live from store
|
||||
LazyVGrid(columns: columns, spacing: 14) {
|
||||
LiveKPICard(
|
||||
title: "Visitors",
|
||||
value: "\(store.metrics.activeVisitors)",
|
||||
subtitle: "Active now",
|
||||
icon: "person.2",
|
||||
accentColor: VelocityTheme.accent,
|
||||
glowColor: VelocityTheme.accent.opacity(0.22),
|
||||
badge: "LIVE"
|
||||
)
|
||||
LiveKPICard(
|
||||
title: "Revenue",
|
||||
value: store.metrics.revenue,
|
||||
subtitle: "30-day forecast",
|
||||
icon: "chart.line.uptrend.xyaxis",
|
||||
accentColor: Color(red: 0.13, green: 0.83, blue: 0.93),
|
||||
glowColor: Color(red: 0.13, green: 0.83, blue: 0.93).opacity(0.18)
|
||||
)
|
||||
LiveKPICard(
|
||||
title: "AI Jobs",
|
||||
value: "\(store.metrics.aiJobs)",
|
||||
subtitle: "Queue depth",
|
||||
icon: "cpu",
|
||||
accentColor: Color(red: 0.60, green: 0.57, blue: 0.99),
|
||||
glowColor: Color(red: 0.60, green: 0.57, blue: 0.99).opacity(0.20)
|
||||
)
|
||||
LiveKPICard(
|
||||
title: "Listings",
|
||||
value: "\(store.metrics.dailyVisitors)",
|
||||
subtitle: "Active units",
|
||||
icon: "building.2",
|
||||
accentColor: VelocityTheme.success,
|
||||
glowColor: VelocityTheme.success.opacity(0.18)
|
||||
)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.4), value: store.metrics.activeVisitors)
|
||||
|
||||
// Sentiment Gauge
|
||||
sentimentGauge
|
||||
|
||||
// System Health
|
||||
systemHealthPanel
|
||||
|
||||
// AI Chat Widget
|
||||
aiChatWidget
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
.navigationTitle("Dashboard")
|
||||
.background(VelocityTheme.background)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
|
||||
// MARK: – Page Header
|
||||
private var pageHeader: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Dashboard")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Project Velocity · v.1.1")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 5) {
|
||||
Circle()
|
||||
.fill(VelocityTheme.success)
|
||||
.frame(width: 7, height: 7)
|
||||
Text("Live")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.success)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Sentiment Gauge
|
||||
private var sentimentGauge: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Image(systemName: "waveform.path.ecg")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
Text("Sentiment Thermometer")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
Text("Showroom Vibe")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
let label = store.metrics.sentimentScore >= 70 ? "Excellent" :
|
||||
store.metrics.sentimentScore >= 50 ? "Good" : "Needs Attention"
|
||||
let labelColor: Color = store.metrics.sentimentScore >= 70 ? VelocityTheme.success :
|
||||
store.metrics.sentimentScore >= 50 ? VelocityTheme.warning : VelocityTheme.danger
|
||||
Text(label)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(labelColor)
|
||||
}
|
||||
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.fill(Color.white.opacity(0.05))
|
||||
.frame(height: 26)
|
||||
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color(red: 0.11, green: 0.30, blue: 0.86),
|
||||
VelocityTheme.accent,
|
||||
Color(red: 0.38, green: 0.65, blue: 0.98)],
|
||||
startPoint: .leading, endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.frame(width: geo.size.width * (store.metrics.sentimentScore / 100), height: 26)
|
||||
.shadow(color: VelocityTheme.accent.opacity(0.6), radius: 6)
|
||||
.animation(.easeInOut(duration: 0.8), value: store.metrics.sentimentScore)
|
||||
|
||||
Text("\(Int(store.metrics.sentimentScore))%")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.frame(height: 26)
|
||||
}
|
||||
.padding(20)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(VelocityTheme.borderAccent, lineWidth: 1))
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: – System Health
|
||||
private var systemHealthPanel: some View {
|
||||
let gauges: [(label: String, value: Double, color: Color)] = [
|
||||
("CPU", store.metrics.systemHealth.cpu, VelocityTheme.accent),
|
||||
("GPU", store.metrics.systemHealth.gpu, Color(red: 0.50, green: 0.56, blue: 0.97)),
|
||||
("Memory", store.metrics.systemHealth.memory, Color(red: 0.13, green: 0.83, blue: 0.93)),
|
||||
]
|
||||
|
||||
return VStack(alignment: .leading, spacing: 14) {
|
||||
HStack {
|
||||
Image(systemName: "cpu").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent)
|
||||
Text("System Health")
|
||||
.font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
HStack(spacing: 4) {
|
||||
Circle().fill(VelocityTheme.success).frame(width: 5, height: 5)
|
||||
Text("Optimal").font(.system(size: 11, weight: .medium)).foregroundStyle(VelocityTheme.success)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 16) {
|
||||
ForEach(gauges, id: \.label) { g in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(g.label).font(.system(size: 10, weight: .medium)).tracking(0.8)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Spacer()
|
||||
Text("\(Int(g.value * 100))%").font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.05)).frame(height: 5)
|
||||
RoundedRectangle(cornerRadius: 3).fill(g.color)
|
||||
.frame(width: geo.size.width * g.value, height: 5)
|
||||
.shadow(color: g.color.opacity(0.6), radius: 4)
|
||||
.animation(.easeInOut(duration: 0.6), value: g.value)
|
||||
}
|
||||
}
|
||||
.frame(height: 5)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(VelocityTheme.borderAccent, lineWidth: 1))
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: – AI Chat Widget
|
||||
private var aiChatWidget: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
Circle().fill(VelocityTheme.accent.opacity(0.18)).frame(width: 34, height: 34)
|
||||
Image(systemName: "sparkles").font(.system(size: 14)).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 5) {
|
||||
Text("AI Onboard").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Image(systemName: "staroflife.fill").font(.system(size: 7)).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
Circle().fill(VelocityTheme.success).frame(width: 5, height: 5)
|
||||
Text("Online").font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(16)
|
||||
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
|
||||
// Messages
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
ForEach(store.dashboardMessages) { msg in
|
||||
ChatBubble(message: msg)
|
||||
.id(msg.id)
|
||||
}
|
||||
if store.isDashboardThinking {
|
||||
TypingIndicator()
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.frame(height: 240)
|
||||
.onChange(of: store.dashboardMessages.count) {
|
||||
if let last = store.dashboardMessages.last {
|
||||
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
|
||||
}
|
||||
}
|
||||
.onChange(of: store.isDashboardThinking) {
|
||||
if store.isDashboardThinking {
|
||||
withAnimation { proxy.scrollTo("typing", anchor: .bottom) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
|
||||
// Input
|
||||
HStack(spacing: 10) {
|
||||
TextField("Ask AI assistant...", text: $chatInput)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.tint(VelocityTheme.accent)
|
||||
.onSubmit { sendDashboardMessage() }
|
||||
|
||||
Button(action: sendDashboardMessage) {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(chatInput.isEmpty ? VelocityTheme.mutedFg : VelocityTheme.accent)
|
||||
}
|
||||
.disabled(chatInput.isEmpty || store.isDashboardThinking)
|
||||
}
|
||||
.padding(14)
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(VelocityTheme.borderAccent, lineWidth: 1))
|
||||
)
|
||||
}
|
||||
|
||||
private func sendDashboardMessage() {
|
||||
let text = chatInput.trimmingCharacters(in: .whitespaces)
|
||||
guard !text.isEmpty else { return }
|
||||
chatInput = ""
|
||||
store.addDashboardMessage(sender: "user", content: text)
|
||||
store.isDashboardThinking = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||||
store.isDashboardThinking = false
|
||||
store.addDashboardMessage(
|
||||
sender: "ai",
|
||||
content: dashboardAIResponse(for: text)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func dashboardAIResponse(for prompt: String) -> String {
|
||||
let p = prompt.lowercased()
|
||||
if p.contains("penthouse") || p.contains("apex") {
|
||||
return "Apex Innovations probability score is now at 85%. I recommend scheduling a closing meeting next Tuesday — sentiment analysis shows high engagement."
|
||||
} else if p.contains("visitor") || p.contains("traffic") {
|
||||
return "Currently \(store.metrics.activeVisitors) active visitors. Zone B (Penthouse Gallery) is generating the highest dwell time today — average 8 minutes."
|
||||
} else if p.contains("revenue") || p.contains("deal") {
|
||||
return "Pipeline value stands at \(store.metrics.revenue) for the 30-day forecast. 3 deals are in final negotiation — I recommend prioritising Mohammed Al-Rashid."
|
||||
} else if p.contains("sentiment") {
|
||||
return "Showroom sentiment is at \(Int(store.metrics.sentimentScore))% — above the excellent threshold. Visitors in Zone D (Reception) show the highest satisfaction scores."
|
||||
}
|
||||
return "I've analysed the current data. Revenue is tracking at \(store.metrics.revenue) with \(store.metrics.activeVisitors) active visitors. Shall I prepare a detailed pipeline report?"
|
||||
}
|
||||
}
|
||||
|
||||
private struct WidgetCard: View {
|
||||
// MARK: – KPI Card (live-bound)
|
||||
private struct LiveKPICard: View {
|
||||
let title: String
|
||||
let value: String
|
||||
let subtitle: String
|
||||
let icon: String
|
||||
let accentColor: Color
|
||||
let glowColor: Color
|
||||
var badge: String? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8).fill(accentColor.opacity(0.12)).frame(width: 32, height: 32)
|
||||
Image(systemName: icon).font(.system(size: 14, weight: .medium)).foregroundStyle(accentColor)
|
||||
}
|
||||
Spacer()
|
||||
if let badge {
|
||||
HStack(spacing: 4) {
|
||||
Circle().fill(VelocityTheme.success).frame(width: 5, height: 5)
|
||||
Text(badge).font(.system(size: 9, weight: .semibold)).foregroundStyle(VelocityTheme.success)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 20)
|
||||
|
||||
Text(title.uppercased())
|
||||
.font(.system(size: 10, weight: .medium)).tracking(1.2)
|
||||
.foregroundStyle(VelocityTheme.mutedFg).padding(.bottom, 4)
|
||||
|
||||
Text(value)
|
||||
.font(.largeTitle.bold())
|
||||
Text(subtitle)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.system(size: 34, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.contentTransition(.numericText())
|
||||
.minimumScaleFactor(0.7).lineLimit(1).padding(.bottom, 4)
|
||||
|
||||
Text(subtitle).font(.system(size: 11)).foregroundStyle(VelocityTheme.subtleFg)
|
||||
}
|
||||
.padding(20)
|
||||
.frame(maxWidth: .infinity, minHeight: 148, alignment: .leading)
|
||||
.background(
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
RoundedRectangle(cornerRadius: 16).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
Ellipse().fill(glowColor).frame(width: 120, height: 90).blur(radius: 28).offset(x: 20, y: 20)
|
||||
VStack {
|
||||
Rectangle()
|
||||
.fill(LinearGradient(colors: [.clear, .white.opacity(0.10), .clear], startPoint: .leading, endPoint: .trailing))
|
||||
.frame(height: 1)
|
||||
Spacer()
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(VelocityTheme.borderAccent, lineWidth: 1))
|
||||
.shadow(color: .black.opacity(0.55), radius: 16, y: 4)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Chat Bubble
|
||||
private struct ChatBubble: View {
|
||||
let message: ChatMessage
|
||||
private var isUser: Bool { message.sender == "user" }
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
if isUser { Spacer(minLength: 40) }
|
||||
|
||||
if !isUser {
|
||||
ZStack {
|
||||
Circle().fill(VelocityTheme.accent.opacity(0.18)).frame(width: 26, height: 26)
|
||||
Image(systemName: "sparkles").font(.system(size: 10)).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
}
|
||||
|
||||
Text(message.content)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(isUser ? .white : VelocityTheme.foreground)
|
||||
.padding(.horizontal, 12).padding(.vertical, 9)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: isUser ? 14 : 14)
|
||||
.fill(isUser
|
||||
? VelocityTheme.accent.opacity(0.85)
|
||||
: Color.white.opacity(0.06))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(isUser ? .clear : Color.white.opacity(0.10), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
|
||||
if isUser {
|
||||
ZStack {
|
||||
Circle().fill(Color(red: 0.08, green: 0.10, blue: 0.18)).frame(width: 26, height: 26)
|
||||
Text("A").font(.system(size: 10, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
}
|
||||
if !isUser { Spacer(minLength: 40) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Typing Indicator
|
||||
private struct TypingIndicator: View {
|
||||
@State private var phase = 0
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
ZStack {
|
||||
Circle().fill(VelocityTheme.accent.opacity(0.18)).frame(width: 26, height: 26)
|
||||
Image(systemName: "sparkles").font(.system(size: 10)).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
ForEach(0..<3, id: \.self) { i in
|
||||
Circle()
|
||||
.fill(VelocityTheme.mutedFg)
|
||||
.frame(width: 6, height: 6)
|
||||
.scaleEffect(phase == i ? 1.4 : 0.8)
|
||||
.animation(.easeInOut(duration: 0.4).repeatForever().delay(Double(i) * 0.15), value: phase)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.vertical, 12)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.06))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.white.opacity(0.10), lineWidth: 1)))
|
||||
Spacer(minLength: 40)
|
||||
}
|
||||
.id("typing")
|
||||
.onAppear {
|
||||
withAnimation { phase = 1 }
|
||||
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
|
||||
phase = (phase + 1) % 3
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 140, alignment: .leading)
|
||||
.padding()
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,19 @@ struct InventoryView: View {
|
||||
private let haptics = UIImpactFeedbackGenerator(style: .light)
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Page header
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Inventory")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Sunseeker · Dream Weaver · Dollhouse")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 20)
|
||||
|
||||
Picker("Mode", selection: $store.mode) {
|
||||
ForEach(InventoryStore.Mode.allCases) { mode in
|
||||
Text(mode.rawValue).tag(mode)
|
||||
@@ -45,7 +57,34 @@ struct InventoryView: View {
|
||||
Group {
|
||||
switch store.mode {
|
||||
case .sunseeker:
|
||||
#if targetEnvironment(simulator)
|
||||
ZStack {
|
||||
VStack(spacing: 14) {
|
||||
Image(systemName: "camera.metering.unknown")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text("AR Not Available in Simulator")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Sunseeker requires a real device with a camera and compass. Run on iPhone or iPad to use this feature.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(24)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(VelocityTheme.borderAccent, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
#else
|
||||
SunseekerPanel(sunNodesReady: $store.sunNodesReady)
|
||||
#endif
|
||||
case .dreamWeaver:
|
||||
DreamWeaverPanel(
|
||||
sourceImage: $store.sourceImage,
|
||||
@@ -63,8 +102,18 @@ struct InventoryView: View {
|
||||
.padding(.bottom, 20)
|
||||
.animation(.easeInOut(duration: 0.25), value: store.mode)
|
||||
}
|
||||
.navigationTitle("Inventory")
|
||||
.background(Color(uiColor: .systemGroupedBackground))
|
||||
.background(VelocityTheme.background)
|
||||
.onAppear {
|
||||
// Dark-theme the segmented control
|
||||
UISegmentedControl.appearance().selectedSegmentTintColor = UIColor(
|
||||
red: 0.231, green: 0.510, blue: 0.965, alpha: 0.85)
|
||||
UISegmentedControl.appearance().setTitleTextAttributes(
|
||||
[.foregroundColor: UIColor.white], for: .selected)
|
||||
UISegmentedControl.appearance().setTitleTextAttributes(
|
||||
[.foregroundColor: UIColor(white: 0.62, alpha: 1)], for: .normal)
|
||||
UISegmentedControl.appearance().backgroundColor = UIColor(
|
||||
red: 0.031, green: 0.039, blue: 0.071, alpha: 1)
|
||||
}
|
||||
.sheet(isPresented: $showCamera) {
|
||||
CameraPicker(image: $store.sourceImage, isPresented: $showCamera)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,960 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OracleView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "message.and.waveform")
|
||||
.font(.system(size: 48))
|
||||
Text("Oracle Chat")
|
||||
.font(.title2.bold())
|
||||
Text("Connect this view to your backend assistant pipeline.")
|
||||
.foregroundStyle(.secondary)
|
||||
// MARK: – Oracle Canvas Modes
|
||||
enum OracleMode: String, CaseIterable {
|
||||
case pipeline = "Pipeline"
|
||||
case teamPerformance = "Team Performance"
|
||||
case accountTimeline = "Account Timeline"
|
||||
case leadMap = "Lead Map"
|
||||
case calendarTasks = "Calendar & Tasks"
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .pipeline: return "square.grid.3x1.below.line.grid.1x2"
|
||||
case .teamPerformance: return "person.3"
|
||||
case .accountTimeline: return "clock.arrow.circlepath"
|
||||
case .leadMap: return "map"
|
||||
case .calendarTasks: return "calendar"
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.navigationTitle("Oracle")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Pipeline mock data (extended with detail fields)
|
||||
struct OracleLeadCard: Identifiable {
|
||||
let id = UUID()
|
||||
let initials: String
|
||||
let name: String
|
||||
let company: String
|
||||
let value: String
|
||||
let status: LeadStatus
|
||||
let phone: String
|
||||
let interest: String
|
||||
let qualification: String
|
||||
}
|
||||
|
||||
private let pipelineData: [(stage: String, cards: [OracleLeadCard])] = [
|
||||
("New", [
|
||||
OracleLeadCard(initials: "JW", name: "James Wilson", company: "Website", value: "AED 3.5M", status: .new,
|
||||
phone: "+971 52 456 7890", interest: "1BR Investment", qualification: "Potential"),
|
||||
]),
|
||||
("Qualified", [
|
||||
OracleLeadCard(initials: "FH", name: "Fatima Hassan", company: "WhatsApp", value: "AED 12M", status: .qualified,
|
||||
phone: "+971 54 321 0987", interest: "3BR + Maid", qualification: "Whale"),
|
||||
OracleLeadCard(initials: "SC", name: "Sarah Chen", company: "Walk-in", value: "AED 6.5M", status: .engaged,
|
||||
phone: "+971 50 987 6543", interest: "2BR Sea View", qualification: "Potential"),
|
||||
]),
|
||||
("Proposal", [
|
||||
OracleLeadCard(initials: "MA", name: "Mohammed Al-Rashid", company: "WhatsApp", value: "AED 15M+", status: .hot,
|
||||
phone: "+971 55 123 4567", interest: "Penthouse Suite", qualification: "Whale"),
|
||||
]),
|
||||
("Closed", [
|
||||
OracleLeadCard(initials: "DK", name: "David Kumar", company: "Walk-in", value: "AED 20M", status: .closed,
|
||||
phone: "+971 56 789 0123", interest: "Full Floor", qualification: "Whale"),
|
||||
]),
|
||||
]
|
||||
|
||||
struct TeamMemberData: Identifiable {
|
||||
let id = UUID()
|
||||
let initials: String; let name: String; let deals: Int; let revenue: String; let trend: String
|
||||
}
|
||||
private let teamData: [TeamMemberData] = [
|
||||
.init(initials: "RA", name: "Rania Al-Farsi", deals: 42, revenue: "$2.1M", trend: "↑ 18%"),
|
||||
.init(initials: "KM", name: "Khaled Mensah", deals: 31, revenue: "$1.6M", trend: "↑ 12%"),
|
||||
.init(initials: "LT", name: "Lina Torres", deals: 28, revenue: "$1.3M", trend: "→ 2%"),
|
||||
.init(initials: "AH", name: "Ahmed Hassan", deals: 19, revenue: "$0.9M", trend: "↓ 5%"),
|
||||
]
|
||||
|
||||
struct OracleTimelineEvent: Identifiable {
|
||||
let id = UUID()
|
||||
let badge: String; let summary: String; let when: String; let detail: String
|
||||
}
|
||||
private let timelineEvents: [OracleTimelineEvent] = [
|
||||
.init(badge: "MEETING", summary: "VR Amenity Tour – Apex Innovations", when: "2h ago",
|
||||
detail: "CFO and Legal Director attended. Strong reaction to the panoramic sea view suite. Follow-up proposal requested by Tuesday."),
|
||||
.init(badge: "EMAIL", summary: "Proposal deck sent to legal team", when: "Yesterday",
|
||||
detail: "58-page proposal + payment plan schedule sent via DocuSign. Legal team has 5 business days to review."),
|
||||
.init(badge: "CALL", summary: "Budget discussion – CFO confirmed", when: "Mon",
|
||||
detail: "Budget ceiling confirmed at AED 15M+. CFO expressed strong preference for a penthouse unit with private terrace."),
|
||||
.init(badge: "VISIT", summary: "Site walkthrough – Penthouse Suite", when: "Last week",
|
||||
detail: "First in-person visit. 45-minute walkthrough of Penthouse A & B. Visitor dwell time analysis showed 'excited' sentiment for 90% of the visit."),
|
||||
]
|
||||
|
||||
struct RegionPin: Identifiable {
|
||||
let id = UUID()
|
||||
let label: String; let country: String; let count: Int; let temp: String; let topLead: String
|
||||
}
|
||||
private let mapPins: [RegionPin] = [
|
||||
.init(label: "UAE", country: "🇦🇪", count: 8, temp: "hot", topLead: "Mohammed Al-Rashid"),
|
||||
.init(label: "Saudi Arabia", country: "🇸🇦", count: 5, temp: "warm", topLead: "Al-Mansour Group"),
|
||||
.init(label: "UK", country: "🇬🇧", count: 3, temp: "cold", topLead: "Rexford Capital"),
|
||||
.init(label: "USA", country: "🇺🇸", count: 4, temp: "warm", topLead: "Apex Innovations"),
|
||||
.init(label: "India", country: "🇮🇳", count: 6, temp: "hot", topLead: "Starlight Systems"),
|
||||
.init(label: "Germany", country: "🇩🇪", count: 2, temp: "cold", topLead: "TechWave GmbH"),
|
||||
]
|
||||
|
||||
struct CalTask: Identifiable {
|
||||
let id = UUID()
|
||||
let title: String; let subtitle: String; let due: String
|
||||
}
|
||||
private let calTasks: [CalTask] = [
|
||||
.init(title: "Follow up with Mohammed", subtitle: "High-value penthouse lead – 2 unread messages", due: "Today 3 PM"),
|
||||
.init(title: "Send contract to Fatima", subtitle: "3BR unit finalised – payment plan to confirm", due: "Tomorrow 10 AM"),
|
||||
.init(title: "Schedule VR tour – James", subtitle: "Website lead, potential 1BR investor", due: "Thu 2 PM"),
|
||||
]
|
||||
|
||||
// MARK: – OracleView (main)
|
||||
struct OracleView: View {
|
||||
@State private var selectedMode: OracleMode = .pipeline
|
||||
@State private var prompt = "Show me a pipeline view by stage for Q4."
|
||||
@State private var insightText = "Pipeline is healthy. Mohammed Al-Rashid is your highest-value close opportunity — follow up within 24 hours."
|
||||
@State private var isSubmitting = false
|
||||
|
||||
// Sheet states
|
||||
@State private var selectedLead: OracleLeadCard? = nil
|
||||
@State private var selectedMember: TeamMemberData? = nil
|
||||
@State private var selectedRegion: RegionPin? = nil
|
||||
@State private var scheduledTask: CalTask? = nil
|
||||
@State private var showScheduleConfirm = false
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
pageHeader
|
||||
.padding(.horizontal, 24).padding(.top, 24).padding(.bottom, 16)
|
||||
|
||||
insightCard
|
||||
.padding(.horizontal, 24).padding(.bottom, 14)
|
||||
|
||||
ScrollView {
|
||||
canvasView
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 120)
|
||||
}
|
||||
}
|
||||
|
||||
promptBar
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(VelocityTheme.background)
|
||||
|
||||
// Lead detail sheet
|
||||
.sheet(item: $selectedLead) { card in
|
||||
LeadDetailSheet(card: card)
|
||||
}
|
||||
// Team member sheet
|
||||
.sheet(item: $selectedMember) { member in
|
||||
MemberDetailSheet(member: member)
|
||||
}
|
||||
// Region callout sheet
|
||||
.sheet(item: $selectedRegion) { pin in
|
||||
RegionDetailSheet(pin: pin)
|
||||
}
|
||||
// Schedule confirmation alert
|
||||
.alert("Confirm Schedule",
|
||||
isPresented: $showScheduleConfirm,
|
||||
presenting: scheduledTask) { task in
|
||||
Button("Schedule") {
|
||||
// In a real app this would create a calendar event
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: { task in
|
||||
Text("Add \"\(task.title)\" to your calendar for \(task.due)?")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Sub-views
|
||||
private var pageHeader: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Oracle").font(.system(size: 28, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text("AI intelligence pipeline").font(.system(size: 12)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
if isSubmitting {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
|
||||
.scaleEffect(0.8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var insightCard: some View {
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(LinearGradient(colors: [Color(red: 0.58, green: 0.77, blue: 0.99), VelocityTheme.accent],
|
||||
startPoint: .top, endPoint: .bottom))
|
||||
.frame(width: 3)
|
||||
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("AI INSIGHT").font(.system(size: 9, weight: .semibold)).tracking(1.5)
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
Text(insightText).font(.system(size: 13)).foregroundStyle(VelocityTheme.foreground).lineLimit(2)
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: selectedMode.icon).font(.system(size: 11)).foregroundStyle(VelocityTheme.accent)
|
||||
Text(selectedMode.rawValue).font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(Color(red: 0.58, green: 0.77, blue: 0.99))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.vertical, 12)
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(red: 0.09, green: 0.15, blue: 0.33).opacity(0.55))
|
||||
.overlay(RoundedRectangle(cornerRadius: 12).stroke(VelocityTheme.borderAccent, lineWidth: 1))
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var canvasView: some View {
|
||||
switch selectedMode {
|
||||
case .pipeline:
|
||||
PipelineCanvas(onSelectLead: { selectedLead = $0 })
|
||||
case .teamPerformance:
|
||||
TeamPerformanceCanvas(onSelectMember: { selectedMember = $0 })
|
||||
case .accountTimeline:
|
||||
AccountTimelineCanvas()
|
||||
case .leadMap:
|
||||
LeadMapCanvas(onSelectRegion: { selectedRegion = $0 })
|
||||
case .calendarTasks:
|
||||
CalendarCanvas(onSchedule: { task in
|
||||
scheduledTask = task
|
||||
showScheduleConfirm = true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Prompt Bar
|
||||
private var promptBar: some View {
|
||||
VStack(spacing: 0) {
|
||||
TextField("Ask Oracle anything…", text: $prompt)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.tint(VelocityTheme.accent)
|
||||
.onSubmit { submitPrompt() }
|
||||
.padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 8)
|
||||
|
||||
HStack {
|
||||
Menu {
|
||||
ForEach(OracleMode.allCases, id: \.self) { mode in
|
||||
Button {
|
||||
selectedMode = mode
|
||||
prompt = modeSamplePrompt(mode)
|
||||
insightText = oracleInsight(for: mode)
|
||||
} label: {
|
||||
Label(mode.rawValue, systemImage: mode.icon)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: selectedMode.icon).font(.system(size: 10))
|
||||
Text(selectedMode.rawValue).font(.system(size: 11, weight: .medium))
|
||||
Image(systemName: "chevron.down").font(.system(size: 8))
|
||||
}
|
||||
.foregroundStyle(Color(red: 0.58, green: 0.77, blue: 0.99))
|
||||
.padding(.horizontal, 10).padding(.vertical, 6)
|
||||
.background(Capsule().fill(VelocityTheme.accent.opacity(0.14))
|
||||
.overlay(Capsule().stroke(VelocityTheme.accent.opacity(0.3), lineWidth: 1)))
|
||||
}
|
||||
Spacer()
|
||||
Button(action: submitPrompt) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(isSubmitting ? VelocityTheme.mutedFg : VelocityTheme.accent)
|
||||
.shadow(color: VelocityTheme.accent.opacity(0.5), radius: 8)
|
||||
if isSubmitting {
|
||||
ProgressView().progressViewStyle(CircularProgressViewStyle(tint: .white)).scaleEffect(0.6)
|
||||
} else {
|
||||
Image(systemName: "paperplane.fill").font(.system(size: 12)).foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
.frame(width: 34, height: 34)
|
||||
}
|
||||
.disabled(isSubmitting || prompt.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
.padding(.horizontal, 12).padding(.bottom, 12)
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.fill(Color(red: 0.039, green: 0.043, blue: 0.063).opacity(0.95))
|
||||
.overlay(RoundedRectangle(cornerRadius: 18).stroke(Color.white.opacity(0.11), lineWidth: 1))
|
||||
.shadow(color: .black.opacity(0.6), radius: 20, y: -4)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: – Prompt logic
|
||||
private func submitPrompt() {
|
||||
let clean = prompt.trimmingCharacters(in: .whitespaces)
|
||||
guard !clean.isEmpty && !isSubmitting else { return }
|
||||
isSubmitting = true
|
||||
let lower = clean.lowercased()
|
||||
if lower.contains("team") || lower.contains("performance") || lower.contains("sales") {
|
||||
selectedMode = .teamPerformance
|
||||
} else if lower.contains("account") || lower.contains("apex") || lower.contains("timeline") {
|
||||
selectedMode = .accountTimeline
|
||||
} else if lower.contains("map") || lower.contains("geographic") || lower.contains("location") {
|
||||
selectedMode = .leadMap
|
||||
} else if lower.contains("calendar") || lower.contains("schedule") || lower.contains("task") {
|
||||
selectedMode = .calendarTasks
|
||||
} else {
|
||||
selectedMode = .pipeline
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.9) {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
insightText = oracleInsight(for: selectedMode)
|
||||
isSubmitting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func modeSamplePrompt(_ mode: OracleMode) -> String {
|
||||
switch mode {
|
||||
case .pipeline: return "Show me a pipeline view by stage for Q4."
|
||||
case .teamPerformance: return "What's the performance of the sales team this month?"
|
||||
case .accountTimeline: return "Find all contacts at Apex Innovations and their recent activity."
|
||||
case .leadMap: return "Give me a geographic map of all leads."
|
||||
case .calendarTasks: return "Schedule follow-ups with the top 3 high-value leads."
|
||||
}
|
||||
}
|
||||
|
||||
private func oracleInsight(for mode: OracleMode) -> String {
|
||||
switch mode {
|
||||
case .pipeline: return "Pipeline is healthy. Mohammed Al-Rashid is your highest-value close opportunity — follow up within 24 hours."
|
||||
case .teamPerformance: return "Rania Al-Farsi leads the team at $2.1M closed. Overall quota attainment is at 87% — ahead of last month."
|
||||
case .accountTimeline: return "Apex Innovations has 4 active stakeholders. Confusion detected in legal stage — recommend expediting contract review."
|
||||
case .leadMap: return "UAE and India show the hottest lead concentration. 8 high-value prospects in UAE require immediate outreach."
|
||||
case .calendarTasks: return "3 high-priority follow-ups scheduled. Mohammed Al-Rashid has 2 unread messages — respond within 24h."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Pipeline Canvas
|
||||
private struct PipelineCanvas: View {
|
||||
let onSelectLead: (OracleLeadCard) -> Void
|
||||
private let cols = [GridItem(.adaptive(minimum: 200), spacing: 12)]
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(columns: cols, alignment: .leading, spacing: 12) {
|
||||
ForEach(pipelineData, id: \.stage) { col in
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text(col.stage.uppercased())
|
||||
.font(.system(size: 10, weight: .semibold)).tracking(1)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Spacer()
|
||||
Text("\(col.cards.count)")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
.padding(.horizontal, 7).padding(.vertical, 3)
|
||||
.background(Capsule().fill(VelocityTheme.accent.opacity(0.12))
|
||||
.overlay(Capsule().stroke(VelocityTheme.accent.opacity(0.2), lineWidth: 1)))
|
||||
}
|
||||
ForEach(col.cards) { card in
|
||||
TappableLeadCard(card: card, onTap: { onSelectLead(card) })
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderSubtle, lineWidth: 1))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TappableLeadCard: View {
|
||||
let card: OracleLeadCard
|
||||
let onTap: () -> Void
|
||||
@State private var pressed = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8).fill(card.status.color.opacity(0.18)).frame(width: 36, height: 36)
|
||||
Text(card.initials).font(.system(size: 12, weight: .bold)).foregroundStyle(card.status.color)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(card.name).font(.system(size: 12, weight: .medium)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text(card.company).font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(card.value).font(.system(size: 11, weight: .semibold)).foregroundStyle(VelocityTheme.accent)
|
||||
Image(systemName: "chevron.right").font(.system(size: 9)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(pressed ? VelocityTheme.accent.opacity(0.10) : Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(pressed ? VelocityTheme.accent.opacity(0.35) : Color.white.opacity(0.06), lineWidth: 1))
|
||||
)
|
||||
.scaleEffect(pressed ? 0.97 : 1.0)
|
||||
.animation(.easeInOut(duration: 0.12), value: pressed)
|
||||
.onTapGesture {
|
||||
pressed = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { pressed = false }
|
||||
onTap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Lead Detail Sheet
|
||||
private struct LeadDetailSheet: View {
|
||||
let card: OracleLeadCard
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
// Avatar + name
|
||||
HStack(spacing: 16) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 14).fill(card.status.color.opacity(0.20)).frame(width: 60, height: 60)
|
||||
Text(card.initials).font(.system(size: 22, weight: .bold)).foregroundStyle(card.status.color)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(card.name).font(.system(size: 20, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||||
HStack(spacing: 6) {
|
||||
Text(card.status.rawValue)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(card.status.color)
|
||||
.padding(.horizontal, 8).padding(.vertical, 3)
|
||||
.background(Capsule().fill(card.status.color.opacity(0.14)))
|
||||
Text(card.qualification).font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
|
||||
// Details grid
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 14) {
|
||||
DetailField(label: "Deal Value", value: card.value)
|
||||
DetailField(label: "Source", value: card.company)
|
||||
DetailField(label: "Interest", value: card.interest)
|
||||
DetailField(label: "Phone", value: card.phone)
|
||||
}
|
||||
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
|
||||
// Action buttons
|
||||
HStack(spacing: 12) {
|
||||
ActionChip(icon: "phone.fill", label: "Call", color: VelocityTheme.success)
|
||||
ActionChip(icon: "message.fill", label: "Message", color: VelocityTheme.accent)
|
||||
ActionChip(icon: "calendar.badge.plus", label: "Schedule", color: Color(red: 0.60, green: 0.57, blue: 0.99))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(24)
|
||||
.background(VelocityTheme.background.ignoresSafeArea())
|
||||
.navigationTitle("Lead Details")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DetailField: View {
|
||||
let label: String; let value: String
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(label.uppercased()).font(.system(size: 9, weight: .semibold)).tracking(1)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(value).font(.system(size: 13, weight: .medium)).foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.white.opacity(0.07), lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
private struct ActionChip: View {
|
||||
let icon: String; let label: String; let color: Color
|
||||
@State private var pressed = false
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: icon).font(.system(size: 12))
|
||||
Text(label).font(.system(size: 12, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(color)
|
||||
.padding(.horizontal, 16).padding(.vertical, 10)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(color.opacity(pressed ? 0.22 : 0.13))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(color.opacity(0.30), lineWidth: 1)))
|
||||
.scaleEffect(pressed ? 0.96 : 1.0)
|
||||
.animation(.easeInOut(duration: 0.12), value: pressed)
|
||||
.onTapGesture { pressed = true; DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { pressed = false } }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Team Performance Canvas
|
||||
private struct TeamPerformanceCanvas: View {
|
||||
let onSelectMember: (TeamMemberData) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
quotaPanel
|
||||
teamListPanel
|
||||
}
|
||||
}
|
||||
|
||||
private var quotaPanel: some View {
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
Circle().stroke(Color.white.opacity(0.06), lineWidth: 10).frame(width: 110, height: 110)
|
||||
Circle()
|
||||
.trim(from: 0, to: 0.87)
|
||||
.stroke(AngularGradient(colors: [VelocityTheme.accent, Color(red: 0.13, green: 0.83, blue: 0.93)],
|
||||
center: .center),
|
||||
style: StrokeStyle(lineWidth: 10, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.frame(width: 110, height: 110)
|
||||
VStack(spacing: 2) {
|
||||
Text("87%").font(.system(size: 26, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text("QUOTA").font(.system(size: 8, weight: .semibold)).tracking(1.2).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Quota Attainment").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Monthly target exceeded").font(.system(size: 11)).foregroundStyle(VelocityTheme.success)
|
||||
Text("Q4 FY2025–26").font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(20)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
|
||||
private var teamListPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("TEAM PERFORMANCE").font(.system(size: 10, weight: .semibold)).tracking(1.2)
|
||||
.foregroundStyle(VelocityTheme.mutedFg).padding(.bottom, 8)
|
||||
ForEach(teamData) { member in
|
||||
TappableTeamRow(member: member, onTap: { onSelectMember(member) })
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
private struct TappableTeamRow: View {
|
||||
let member: TeamMemberData
|
||||
let onTap: () -> Void
|
||||
@State private var pressed = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8).fill(VelocityTheme.accent.opacity(0.18)).frame(width: 36, height: 36)
|
||||
Text(member.initials).font(.system(size: 12, weight: .bold)).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(member.name).font(.system(size: 13, weight: .medium)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text("\(member.deals) deals closed").font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(member.revenue).font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.accent)
|
||||
Text(member.trend)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(member.trend.hasPrefix("↑") ? VelocityTheme.success :
|
||||
member.trend.hasPrefix("↓") ? VelocityTheme.danger : VelocityTheme.mutedFg)
|
||||
}
|
||||
Image(systemName: "chevron.right").font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 10)
|
||||
.fill(pressed ? VelocityTheme.accent.opacity(0.08) : Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(pressed ? VelocityTheme.accent.opacity(0.25) : Color.white.opacity(0.06), lineWidth: 1)))
|
||||
.scaleEffect(pressed ? 0.98 : 1.0)
|
||||
.animation(.easeInOut(duration: 0.12), value: pressed)
|
||||
.onTapGesture {
|
||||
pressed = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { pressed = false }
|
||||
onTap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Team Member Detail Sheet
|
||||
private struct MemberDetailSheet: View {
|
||||
let member: TeamMemberData
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
HStack(spacing: 16) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 14).fill(VelocityTheme.accent.opacity(0.18)).frame(width: 60, height: 60)
|
||||
Text(member.initials).font(.system(size: 22, weight: .bold)).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(member.name).font(.system(size: 20, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Sales Executive").font(.system(size: 12)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 14) {
|
||||
DetailField(label: "Revenue Closed", value: member.revenue)
|
||||
DetailField(label: "Deals Closed", value: "\(member.deals)")
|
||||
DetailField(label: "Trend", value: member.trend)
|
||||
DetailField(label: "Period", value: "Q4 FY2025–26")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(24)
|
||||
.background(VelocityTheme.background.ignoresSafeArea())
|
||||
.navigationTitle("Team Member")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") { dismiss() }.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Account Timeline Canvas
|
||||
private struct AccountTimelineCanvas: View {
|
||||
@State private var expandedId: UUID? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
// Account overview
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("ACCOUNT OVERVIEW").font(.system(size: 9, weight: .semibold)).tracking(1.5).foregroundStyle(VelocityTheme.accent)
|
||||
Text("Apex Innovations").font(.system(size: 24, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||||
HStack(spacing: 14) {
|
||||
InfoMini(label: "Deal Value", value: "AED 15M+")
|
||||
InfoMini(label: "Primary Contact", value: "CEO – James T.")
|
||||
InfoMini(label: "Industry", value: "Technology")
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
|
||||
// Expandable timeline
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Activity Timeline").font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground).padding(.bottom, 16)
|
||||
ForEach(Array(timelineEvents.enumerated()), id: \.offset) { i, event in
|
||||
TimelineEventRow(event: event, isLast: i == timelineEvents.count - 1,
|
||||
isExpanded: expandedId == event.id) {
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
expandedId = expandedId == event.id ? nil : event.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TimelineEventRow: View {
|
||||
let event: OracleTimelineEvent
|
||||
let isLast: Bool
|
||||
let isExpanded: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
VStack(spacing: 0) {
|
||||
Circle().fill(VelocityTheme.accent).frame(width: 10, height: 10)
|
||||
.overlay(Circle().stroke(VelocityTheme.background, lineWidth: 2))
|
||||
if !isLast {
|
||||
Rectangle()
|
||||
.fill(LinearGradient(colors: [VelocityTheme.accent.opacity(0.5), .clear],
|
||||
startPoint: .top, endPoint: .bottom))
|
||||
.frame(width: 2)
|
||||
.frame(height: isExpanded ? 100 : 50)
|
||||
.animation(.easeInOut(duration: 0.25), value: isExpanded)
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(event.badge).font(.system(size: 9, weight: .bold)).tracking(1.2)
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
.padding(.horizontal, 7).padding(.vertical, 3)
|
||||
.background(RoundedRectangle(cornerRadius: 4).fill(VelocityTheme.accent.opacity(0.15))
|
||||
.overlay(RoundedRectangle(cornerRadius: 4).stroke(VelocityTheme.accent.opacity(0.25), lineWidth: 1)))
|
||||
Spacer()
|
||||
Text(event.when).font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
||||
.font(.system(size: 9)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Text(event.summary).font(.system(size: 13, weight: .medium)).foregroundStyle(VelocityTheme.foreground)
|
||||
if isExpanded {
|
||||
Text(event.detail).font(.system(size: 12)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
.padding(.top, 4)
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 10)
|
||||
.fill(isExpanded ? VelocityTheme.accent.opacity(0.06) : Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(isExpanded ? VelocityTheme.accent.opacity(0.2) : Color.white.opacity(0.06), lineWidth: 1)))
|
||||
.onTapGesture { onTap() }
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct InfoMini: View {
|
||||
let label: String; let value: String
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label).font(.system(size: 9)).tracking(0.8).foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(value).font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
.padding(10)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.white.opacity(0.06), lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Lead Map Canvas
|
||||
private struct LeadMapCanvas: View {
|
||||
let onSelectRegion: (RegionPin) -> Void
|
||||
private let cols = [GridItem(.adaptive(minimum: 140), spacing: 10)]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(spacing: 16) {
|
||||
LegendDot(color: VelocityTheme.danger, label: "Hot Lead")
|
||||
LegendDot(color: Color(red: 0.13, green: 0.83, blue: 0.93), label: "Warm Lead")
|
||||
LegendDot(color: VelocityTheme.mutedFg, label: "Cold Lead")
|
||||
Spacer()
|
||||
}
|
||||
LazyVGrid(columns: cols, spacing: 10) {
|
||||
ForEach(mapPins) { pin in
|
||||
TappableRegionPin(pin: pin, onTap: { onSelectRegion(pin) })
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
private struct TappableRegionPin: View {
|
||||
let pin: RegionPin
|
||||
let onTap: () -> Void
|
||||
@State private var pressed = false
|
||||
|
||||
private var pinColor: Color {
|
||||
pin.temp == "hot" ? VelocityTheme.danger :
|
||||
pin.temp == "warm" ? Color(red: 0.13, green: 0.83, blue: 0.93) : VelocityTheme.mutedFg
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Text(pin.country).font(.system(size: 24))
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(pin.label).font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
HStack(spacing: 4) {
|
||||
Circle().fill(pinColor).frame(width: 6, height: 6).shadow(color: pinColor.opacity(0.8), radius: 3)
|
||||
Text("\(pin.count) leads").font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.right.circle")
|
||||
.font(.system(size: 13)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 10)
|
||||
.fill(pressed ? pinColor.opacity(0.12) : Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(pinColor.opacity(pressed ? 0.5 : 0.25), lineWidth: 1)))
|
||||
.scaleEffect(pressed ? 0.97 : 1.0)
|
||||
.animation(.easeInOut(duration: 0.12), value: pressed)
|
||||
.onTapGesture {
|
||||
pressed = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { pressed = false }
|
||||
onTap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LegendDot: View {
|
||||
let color: Color; let label: String
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Circle().fill(color).frame(width: 8, height: 8).shadow(color: color.opacity(0.8), radius: 3)
|
||||
Text(label).font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Region Detail Sheet
|
||||
private struct RegionDetailSheet: View {
|
||||
let pin: RegionPin
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
private var pinColor: Color {
|
||||
pin.temp == "hot" ? VelocityTheme.danger :
|
||||
pin.temp == "warm" ? Color(red: 0.13, green: 0.83, blue: 0.93) : VelocityTheme.mutedFg
|
||||
}
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
HStack(spacing: 16) {
|
||||
Text(pin.country).font(.system(size: 52))
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(pin.label).font(.system(size: 22, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||||
HStack(spacing: 6) {
|
||||
Circle().fill(pinColor).frame(width: 7, height: 7)
|
||||
Text(pin.temp.capitalized + " Market")
|
||||
.font(.system(size: 12, weight: .medium)).foregroundStyle(pinColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 14) {
|
||||
DetailField(label: "Active Leads", value: "\(pin.count)")
|
||||
DetailField(label: "Top Lead", value: pin.topLead)
|
||||
DetailField(label: "Temperature", value: pin.temp.capitalized)
|
||||
DetailField(label: "Priority", value: pin.temp == "hot" ? "High 🔴" : pin.temp == "warm" ? "Medium 🟡" : "Low ⚪")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(24)
|
||||
.background(VelocityTheme.background.ignoresSafeArea())
|
||||
.navigationTitle("Region Details")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") { dismiss() }.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Calendar Canvas
|
||||
private struct CalendarCanvas: View {
|
||||
let onSchedule: (CalTask) -> Void
|
||||
let days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
weekPanel
|
||||
tasksPanel
|
||||
}
|
||||
}
|
||||
|
||||
private var weekPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Weekly Schedule").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
HStack(spacing: 6) {
|
||||
ForEach(days, id: \.self) { day in
|
||||
Text(day).font(.system(size: 10, weight: .medium)).tracking(0.5)
|
||||
.foregroundStyle(VelocityTheme.mutedFg).frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
ForEach(Array(days.enumerated()), id: \.offset) { i, _ in
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(i == 2 ? VelocityTheme.accent.opacity(0.15) : Color.white.opacity(0.03))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(i == 2 ? VelocityTheme.accent.opacity(0.3) : Color.white.opacity(0.05), lineWidth: 1))
|
||||
.frame(height: 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
|
||||
private var tasksPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 5) {
|
||||
Circle().fill(Color(red: 0.13, green: 0.83, blue: 0.93)).frame(width: 6, height: 6)
|
||||
Text("Tasks & Actions").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
.padding(.bottom, 4)
|
||||
ForEach(calTasks) { task in
|
||||
CalTaskRow(task: task, onSchedule: { onSchedule(task) })
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
private struct CalTaskRow: View {
|
||||
let task: CalTask
|
||||
let onSchedule: () -> Void
|
||||
@State private var scheduled = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(task.title).font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
Text(scheduled ? "Scheduled ✓" : "Action")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(scheduled ? VelocityTheme.success : VelocityTheme.mutedFg)
|
||||
.padding(.horizontal, 6).padding(.vertical, 3)
|
||||
.background(RoundedRectangle(cornerRadius: 4)
|
||||
.fill(scheduled ? VelocityTheme.success.opacity(0.12) : Color.white.opacity(0.06)))
|
||||
}
|
||||
Text(task.subtitle).font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg).lineLimit(2)
|
||||
HStack {
|
||||
Image(systemName: "clock").font(.system(size: 10)).foregroundStyle(VelocityTheme.accent)
|
||||
Text(task.due).font(.system(size: 11)).foregroundStyle(VelocityTheme.accent)
|
||||
Spacer()
|
||||
Button {
|
||||
onSchedule()
|
||||
withAnimation(.easeInOut(duration: 0.3)) { scheduled = true }
|
||||
} label: {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: scheduled ? "checkmark" : "calendar.badge.plus")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
Text(scheduled ? "Scheduled" : "Schedule")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 12).padding(.vertical, 5)
|
||||
.background(RoundedRectangle(cornerRadius: 7)
|
||||
.fill(scheduled ? VelocityTheme.success : VelocityTheme.accent)
|
||||
.shadow(color: (scheduled ? VelocityTheme.success : VelocityTheme.accent).opacity(0.4), radius: 6))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 10)
|
||||
.fill(scheduled ? VelocityTheme.success.opacity(0.05) : Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(scheduled ? VelocityTheme.success.opacity(0.2) : Color.white.opacity(0.06), lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,413 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SentinelView: View {
|
||||
private var store: AppStore { AppStore.shared }
|
||||
private let indigo = Color(red: 0.60, green: 0.57, blue: 0.99)
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "faceid")
|
||||
.font(.system(size: 48))
|
||||
Text("Sentinel")
|
||||
.font(.title2.bold())
|
||||
Text("FaceID and visitor event logs surface here.")
|
||||
.foregroundStyle(.secondary)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
pageHeader
|
||||
kpiGrid
|
||||
analyticsRow
|
||||
bottomRow
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.navigationTitle("Sentinel")
|
||||
.background(VelocityTheme.background)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
|
||||
// MARK: – Sub-views extracted so the type-checker can cope
|
||||
private var pageHeader: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Sentinel")
|
||||
.font(.system(size: 28, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text("FaceID · visitor analytics · real-time alerts")
|
||||
.font(.system(size: 12)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
|
||||
private var kpiGrid: some View {
|
||||
let cols = [GridItem(.adaptive(minimum: 180), spacing: 12)]
|
||||
return LazyVGrid(columns: cols, spacing: 12) {
|
||||
SentinelKPI(icon: "person.2.fill", iconColor: VelocityTheme.accent,
|
||||
label: "Active Visitors", value: "\(store.visitors.count)",
|
||||
sub: "Currently tracked", badge: "LIVE")
|
||||
SentinelKPI(icon: "waveform.path.ecg", iconColor: VelocityTheme.success,
|
||||
label: "Avg Sentiment", value: "\(avgSentiment)%",
|
||||
sub: "Overall mood")
|
||||
SentinelKPI(icon: "eye.fill", iconColor: indigo,
|
||||
label: "Detection Accuracy", value: "\(avgConfidence)%",
|
||||
sub: "Avg confidence")
|
||||
SentinelKPI(icon: "faceid", iconColor: VelocityTheme.warning,
|
||||
label: "Tracked Today", value: "47",
|
||||
sub: "Unique faces")
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.4), value: store.visitors.count)
|
||||
}
|
||||
|
||||
private var analyticsRow: some View {
|
||||
let cols = [GridItem(.flexible(), spacing: 14), GridItem(.flexible(minimum: 260), spacing: 14)]
|
||||
return LazyVGrid(columns: cols, alignment: .leading, spacing: 14) {
|
||||
ZoneAnalyticsPanel()
|
||||
ClientInsightsPanel()
|
||||
}
|
||||
}
|
||||
|
||||
private var bottomRow: some View {
|
||||
let cols = [GridItem(.flexible(), spacing: 14),
|
||||
GridItem(.flexible(), spacing: 14),
|
||||
GridItem(.flexible(), spacing: 14)]
|
||||
return LazyVGrid(columns: cols, alignment: .leading, spacing: 14) {
|
||||
SentimentDistributionPanel(visitors: store.visitors)
|
||||
DwellTimePanel()
|
||||
AlertPanel(isActive: store.isAlertActive, message: store.alertMessage)
|
||||
}
|
||||
}
|
||||
|
||||
private var avgSentiment: Int {
|
||||
guard !store.visitors.isEmpty else { return 0 }
|
||||
let total = store.visitors.reduce(0) { $0 + $1.sentiment.score }
|
||||
return total / store.visitors.count
|
||||
}
|
||||
|
||||
private var avgConfidence: Int {
|
||||
guard !store.visitors.isEmpty else { return 0 }
|
||||
let total = store.visitors.reduce(0.0) { $0 + $1.confidence }
|
||||
return Int((total / Double(store.visitors.count)) * 100)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – KPI Card
|
||||
private struct SentinelKPI: View {
|
||||
let icon: String; let iconColor: Color
|
||||
let label: String; let value: String; let sub: String
|
||||
var badge: String? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8).fill(iconColor.opacity(0.14)).frame(width: 34, height: 34)
|
||||
Image(systemName: icon).font(.system(size: 14)).foregroundStyle(iconColor)
|
||||
}
|
||||
Spacer()
|
||||
if let badge {
|
||||
HStack(spacing: 4) {
|
||||
Circle().fill(VelocityTheme.success).frame(width: 5, height: 5)
|
||||
Text(badge).font(.system(size: 9, weight: .semibold)).foregroundStyle(VelocityTheme.success)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(label.uppercased()).font(.system(size: 10, weight: .medium)).tracking(1.0)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(value).font(.system(size: 30, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
.contentTransition(.numericText())
|
||||
Text(sub).font(.system(size: 11)).foregroundStyle(VelocityTheme.subtleFg)
|
||||
}
|
||||
.padding(18)
|
||||
.frame(maxWidth: .infinity, minHeight: 140, alignment: .topLeading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(iconColor.opacity(0.18), lineWidth: 1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Zone Analytics
|
||||
private struct ZoneAnalyticsPanel: View {
|
||||
private let zones: [(id: String, name: String, count: Int, sentiment: Int)] = [
|
||||
("A", "Main Showroom", 5, 72),
|
||||
("B", "Penthouse Gallery",3, 85),
|
||||
("C", "Amenity Deck VR", 2, 68),
|
||||
("D", "Reception", 2, 90),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "mappin.and.ellipse").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent)
|
||||
Text("Zone Analytics").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
ForEach(zones, id: \.id) { zone in
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 6).fill(VelocityTheme.accent.opacity(0.14)).frame(width: 28, height: 28)
|
||||
Text(zone.id).font(.system(size: 11, weight: .bold)).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(zone.name).font(.system(size: 12, weight: .medium)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text("\(zone.count) visitors").font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 4) {
|
||||
let c: Color = zone.sentiment >= 80 ? VelocityTheme.success :
|
||||
zone.sentiment >= 60 ? VelocityTheme.accent : VelocityTheme.warning
|
||||
Circle().fill(c).frame(width: 7, height: 7)
|
||||
Text("\(zone.sentiment)%").font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.white.opacity(0.06), lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Client Insights
|
||||
private struct ClientInsightsPanel: View {
|
||||
private struct Insight {
|
||||
let name: String; let stage: String; let sentiment: String
|
||||
let score: Int; let insight: String; let color: Color
|
||||
|
||||
var icon: String {
|
||||
score >= 80 ? "arrow.up.right" : score >= 50 ? "minus" : "exclamationmark.triangle"
|
||||
}
|
||||
var scoreColor: Color {
|
||||
score >= 80 ? VelocityTheme.success : score >= 50 ? VelocityTheme.warning : VelocityTheme.danger
|
||||
}
|
||||
}
|
||||
|
||||
private let insights: [Insight] = [
|
||||
.init(name: "Quantum Dynamics", stage: "Proposal Review", sentiment: "Positive", score: 92,
|
||||
insight: "Key decision maker showed high engagement. Emphasise custom penthouse integration.",
|
||||
color: VelocityTheme.success),
|
||||
.init(name: "Nebula Ventures", stage: "Discovery", sentiment: "Neutral", score: 45,
|
||||
insight: "Initial interest detected but hesitation around pricing model tier.",
|
||||
color: VelocityTheme.warning),
|
||||
.init(name: "Apex Industries", stage: "Negotiation", sentiment: "Mixed", score: 68,
|
||||
insight: "Confusion markers during contract terms review. Legal team is the blocker.",
|
||||
color: VelocityTheme.danger),
|
||||
.init(name: "Starlight Systems", stage: "Initial Contact", sentiment: "Positive", score: 78,
|
||||
insight: "Strong ESG engagement. Align pitch with sustainability goals to accelerate close.",
|
||||
color: VelocityTheme.accent),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
insightHeader
|
||||
insightGrid
|
||||
}
|
||||
.padding(16)
|
||||
.background(RoundedRectangle(cornerRadius: 14)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
|
||||
private var insightHeader: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "sparkles").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent)
|
||||
Text("AI Strategic Insights")
|
||||
.font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
Text("LIVE ANALYSIS").font(.system(size: 8, weight: .bold)).tracking(1)
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
.padding(.horizontal, 6).padding(.vertical, 3)
|
||||
.background(RoundedRectangle(cornerRadius: 4).fill(VelocityTheme.accent.opacity(0.12))
|
||||
.overlay(RoundedRectangle(cornerRadius: 4)
|
||||
.stroke(VelocityTheme.accent.opacity(0.2), lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
private var insightGrid: some View {
|
||||
let cols = [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)]
|
||||
return LazyVGrid(columns: cols, alignment: .leading, spacing: 10) {
|
||||
ForEach(insights, id: \.name) { item in
|
||||
InsightCard(
|
||||
name: item.name, stage: item.stage, sentiment: item.sentiment,
|
||||
score: item.score, insight: item.insight, color: item.color,
|
||||
icon: item.icon, scoreColor: item.scoreColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct InsightCard: View {
|
||||
struct Item {
|
||||
let name: String; let stage: String; let sentiment: String
|
||||
let score: Int; let insight: String; let color: Color
|
||||
var icon: String { score >= 80 ? "arrow.up.right" : score >= 50 ? "minus" : "exclamationmark.triangle" }
|
||||
var scoreColor: Color { score >= 80 ? VelocityTheme.success : score >= 50 ? VelocityTheme.warning : VelocityTheme.danger }
|
||||
}
|
||||
|
||||
// Accept ClientInsightsPanel.Insight via protocol duck-typing workaround:
|
||||
let name: String; let stage: String; let sentiment: String
|
||||
let score: Int; let insight: String; let color: Color
|
||||
let icon: String; let scoreColor: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 6).fill(color.opacity(0.18)).frame(width: 28, height: 28)
|
||||
Image(systemName: icon).font(.system(size: 11)).foregroundStyle(color)
|
||||
}
|
||||
Spacer()
|
||||
Text("\(score)").font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(scoreColor)
|
||||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||
.background(RoundedRectangle(cornerRadius: 4).fill(scoreColor.opacity(0.15)))
|
||||
}
|
||||
Text(name).font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground).lineLimit(1)
|
||||
Text(insight).font(.system(size: 10))
|
||||
.foregroundStyle(VelocityTheme.mutedFg).lineLimit(3)
|
||||
HStack {
|
||||
Text(stage).font(.system(size: 9)).foregroundStyle(VelocityTheme.subtleFg)
|
||||
Spacer()
|
||||
Text(sentiment).font(.system(size: 9, weight: .semibold)).foregroundStyle(color)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(color.opacity(0.15), lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: – Sentiment Distribution
|
||||
private struct SentimentDistributionPanel: View {
|
||||
let visitors: [Visitor]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "chart.bar").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent)
|
||||
Text("Sentiment").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
ForEach(SentimentType.allCases, id: \.self) { type in
|
||||
let count = visitors.filter { $0.sentiment == type }.count
|
||||
let fraction = visitors.isEmpty ? 0.0 : Double(count) / Double(visitors.count)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(type.emoji).font(.system(size: 14))
|
||||
Text(type.rawValue.capitalized).font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
Spacer()
|
||||
Text("\(count)").font(.system(size: 11, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.06)).frame(height: 5)
|
||||
RoundedRectangle(cornerRadius: 3).fill(type.color)
|
||||
.frame(width: geo.size.width * fraction, height: 5)
|
||||
.animation(.easeOut(duration: 0.6), value: fraction)
|
||||
}
|
||||
}
|
||||
.frame(height: 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Dwell Time Panel
|
||||
private struct DwellTimePanel: View {
|
||||
private let data: [(range: String, count: Int, trend: String)] = [
|
||||
("< 5 min", 3, "down"),
|
||||
("5–15 min", 5, "up"),
|
||||
("15–30 min", 8, "up"),
|
||||
("> 30 min", 4, "stable"),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "timer").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent)
|
||||
Text("Dwell Time").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
let cols = [GridItem(.flexible(), spacing: 8), GridItem(.flexible(), spacing: 8)]
|
||||
LazyVGrid(columns: cols, spacing: 8) {
|
||||
ForEach(data, id: \.range) { item in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(item.range).font(.system(size: 9)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
Spacer()
|
||||
Image(systemName: item.trend == "up" ? "arrow.up.right" :
|
||||
item.trend == "down" ? "arrow.down.right" : "minus")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(item.trend == "up" ? VelocityTheme.success :
|
||||
item.trend == "down" ? VelocityTheme.danger : VelocityTheme.mutedFg)
|
||||
}
|
||||
Text("\(item.count)").font(.system(size: 22, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text("visitors").font(.system(size: 9)).foregroundStyle(VelocityTheme.subtleFg)
|
||||
}
|
||||
.padding(10)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.white.opacity(0.06), lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Alert Panel
|
||||
private struct AlertPanel: View {
|
||||
let isActive: Bool
|
||||
let message: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "bell.badge").font(.system(size: 12)).foregroundStyle(VelocityTheme.warning)
|
||||
Text("Alerts").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
Text(isActive ? "Active" : "Clear")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(isActive ? VelocityTheme.warning : VelocityTheme.success)
|
||||
.padding(.horizontal, 7).padding(.vertical, 3)
|
||||
.background(Capsule().fill((isActive ? VelocityTheme.warning : VelocityTheme.success).opacity(0.15))
|
||||
.overlay(Capsule().stroke((isActive ? VelocityTheme.warning : VelocityTheme.success).opacity(0.25), lineWidth: 1)))
|
||||
}
|
||||
|
||||
if isActive {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 14)).foregroundStyle(VelocityTheme.warning)
|
||||
Text("Sentiment Alert").font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.warning)
|
||||
}
|
||||
Text(message).font(.system(size: 12)).foregroundStyle(VelocityTheme.foreground).lineLimit(3)
|
||||
Text("Just now").font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(VelocityTheme.warning.opacity(0.08))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(VelocityTheme.warning.opacity(0.3), lineWidth: 1)))
|
||||
.transition(.asymmetric(insertion: .scale(scale: 0.95).combined(with: .opacity),
|
||||
removal: .scale(scale: 0.95).combined(with: .opacity)))
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "checkmark.shield.fill")
|
||||
.font(.system(size: 14)).foregroundStyle(VelocityTheme.success)
|
||||
Text("All Clear").font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.success)
|
||||
}
|
||||
Text("No alerts at this time").font(.system(size: 12)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text("System nominal").font(.system(size: 10)).foregroundStyle(VelocityTheme.subtleFg)
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(VelocityTheme.success.opacity(0.08))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(VelocityTheme.success.opacity(0.3), lineWidth: 1)))
|
||||
.transition(.asymmetric(insertion: .scale(scale: 0.95).combined(with: .opacity),
|
||||
removal: .scale(scale: 0.95).combined(with: .opacity)))
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.animation(.easeInOut(duration: 0.3), value: isActive)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,140 @@ import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Backend") {
|
||||
LabeledContent("ComfyUI Endpoint", value: "http://192.168.x.x:8000")
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
// Page header
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Settings")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Configuration")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Section("Display") {
|
||||
LabeledContent("Orientation", value: "Landscape Only")
|
||||
|
||||
// System (live) section
|
||||
SettingsSection(title: "System") {
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.fill(VelocityTheme.success.opacity(0.12)).frame(width: 30, height: 30)
|
||||
Image(systemName: "bolt.fill")
|
||||
.font(.system(size: 13, weight: .medium)).foregroundStyle(VelocityTheme.success)
|
||||
}
|
||||
Text("Connection Status").font(.system(size: 14)).foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
HStack(spacing: 5) {
|
||||
Circle().fill(VelocityTheme.success).frame(width: 6, height: 6)
|
||||
Text("Online").font(.system(size: 12, weight: .medium)).foregroundStyle(VelocityTheme.success)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16).padding(.vertical, 12)
|
||||
}
|
||||
|
||||
// Backend section
|
||||
SettingsSection(title: "Backend") {
|
||||
SettingsRow(label: "ComfyUI Endpoint",
|
||||
value: "http://192.168.x.x:8000",
|
||||
icon: "server.rack",
|
||||
accentColor: VelocityTheme.accent)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(label: "Dream Weaver Path",
|
||||
value: "/dream-weaver",
|
||||
icon: "arrow.triangle.branch",
|
||||
accentColor: VelocityTheme.accent)
|
||||
}
|
||||
|
||||
// Display section
|
||||
SettingsSection(title: "Display") {
|
||||
SettingsRow(label: "Orientation",
|
||||
value: "Landscape Only",
|
||||
icon: "rectangle.landscape.rotate",
|
||||
accentColor: VelocityTheme.mutedFg)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(label: "Theme",
|
||||
value: "Dark",
|
||||
icon: "moon.fill",
|
||||
accentColor: Color(red: 0.60, green: 0.57, blue: 0.99))
|
||||
}
|
||||
|
||||
// App info section
|
||||
SettingsSection(title: "About") {
|
||||
SettingsRow(label: "Version",
|
||||
value: "1.1.0",
|
||||
icon: "info.circle",
|
||||
accentColor: VelocityTheme.mutedFg)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(label: "Build",
|
||||
value: "SwiftUI · iOS 17+",
|
||||
icon: "hammer",
|
||||
accentColor: VelocityTheme.mutedFg)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.padding(24)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(VelocityTheme.background)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsSection<Content: View>: View {
|
||||
let title: String
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(title.uppercased())
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.tracking(1.2)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.padding(.bottom, 8)
|
||||
.padding(.horizontal, 4)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
content
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsRow: View {
|
||||
let label: String
|
||||
let value: String
|
||||
let icon: String
|
||||
let accentColor: Color
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.fill(accentColor.opacity(0.12))
|
||||
.frame(width: 30, height: 30)
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(accentColor)
|
||||
}
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(value)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
|
||||
369
iOS/velocity/velocity.xcodeproj/project.pbxproj
Normal file
369
iOS/velocity/velocity.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,369 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
A27B23462F58DAF100A74A49 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = A27B23452F58DAF100A74A49 /* Alamofire */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
A27B23152F58D9C300A74A49 /* velocity.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = velocity.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
A27B23172F58D9C300A74A49 /* velocity */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = velocity;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
A27B23122F58D9C300A74A49 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A27B23462F58DAF100A74A49 /* Alamofire in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
A27B230C2F58D9C300A74A49 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A27B23172F58D9C300A74A49 /* velocity */,
|
||||
A27B23162F58D9C300A74A49 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A27B23162F58D9C300A74A49 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A27B23152F58D9C300A74A49 /* velocity.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
A27B23142F58D9C300A74A49 /* velocity */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = A27B23202F58D9C400A74A49 /* Build configuration list for PBXNativeTarget "velocity" */;
|
||||
buildPhases = (
|
||||
A27B23112F58D9C300A74A49 /* Sources */,
|
||||
A27B23122F58D9C300A74A49 /* Frameworks */,
|
||||
A27B23132F58D9C300A74A49 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
A27B23172F58D9C300A74A49 /* velocity */,
|
||||
);
|
||||
name = velocity;
|
||||
packageProductDependencies = (
|
||||
A27B23452F58DAF100A74A49 /* Alamofire */,
|
||||
);
|
||||
productName = velocity;
|
||||
productReference = A27B23152F58D9C300A74A49 /* velocity.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
A27B230D2F58D9C300A74A49 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2630;
|
||||
LastUpgradeCheck = 2630;
|
||||
TargetAttributes = {
|
||||
A27B23142F58D9C300A74A49 = {
|
||||
CreatedOnToolsVersion = 26.3;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = A27B23102F58D9C300A74A49 /* Build configuration list for PBXProject "velocity" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = A27B230C2F58D9C300A74A49;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
A27B23442F58DAF100A74A49 /* XCRemoteSwiftPackageReference "Alamofire" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = A27B23162F58D9C300A74A49 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
A27B23142F58D9C300A74A49 /* velocity */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
A27B23132F58D9C300A74A49 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
A27B23112F58D9C300A74A49 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
A27B231E2F58D9C400A74A49 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
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;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
A27B231F2F58D9C400A74A49 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = 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;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
A27B23212F58D9C400A74A49 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = L29922NHD9;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "\"Used to capture rooms for the DreamWeaver AI redesign feature.\"";
|
||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "\"Used to calculate the sun's path for the Sunseeker AR overlay.\"\n\"Used to calculate the sun's path for the Sunseeker AR overlay.\"\n\"Used to calculate the sun's path for the Sunseeker AR overlay.\"\n";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.desineouron.velocity;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
A27B23222F58D9C400A74A49 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = L29922NHD9;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "\"Used to capture rooms for the DreamWeaver AI redesign feature.\"";
|
||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "\"Used to calculate the sun's path for the Sunseeker AR overlay.\"\n\"Used to calculate the sun's path for the Sunseeker AR overlay.\"\n\"Used to calculate the sun's path for the Sunseeker AR overlay.\"\n";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.desineouron.velocity;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
A27B23102F58D9C300A74A49 /* Build configuration list for PBXProject "velocity" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A27B231E2F58D9C400A74A49 /* Debug */,
|
||||
A27B231F2F58D9C400A74A49 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
A27B23202F58D9C400A74A49 /* Build configuration list for PBXNativeTarget "velocity" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A27B23212F58D9C400A74A49 /* Debug */,
|
||||
A27B23222F58D9C400A74A49 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
A27B23442F58DAF100A74A49 /* XCRemoteSwiftPackageReference "Alamofire" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/Alamofire/Alamofire";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 5.0.0;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
A27B23452F58DAF100A74A49 /* Alamofire */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = A27B23442F58DAF100A74A49 /* XCRemoteSwiftPackageReference "Alamofire" */;
|
||||
productName = Alamofire;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = A27B230D2F58D9C300A74A49 /* Project object */;
|
||||
}
|
||||
7
iOS/velocity/velocity.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
iOS/velocity/velocity.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"originHash" : "11b78eba97192d19796cff581fdf69b3e65b441188b1448a1b67e5d7b825a354",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "alamofire",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Alamofire/Alamofire",
|
||||
"state" : {
|
||||
"revision" : "3f99050e75bbc6fe71fc323adabb039756680016",
|
||||
"version" : "5.11.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
173
iOS/velocity/velocity/App/ContentView.swift
Normal file
173
iOS/velocity/velocity/App/ContentView.swift
Normal file
@@ -0,0 +1,173 @@
|
||||
import SwiftUI
|
||||
|
||||
enum AppSection: String, CaseIterable, Hashable, Identifiable {
|
||||
var id: String { rawValue }
|
||||
case dashboard = "Dashboard"
|
||||
case oracle = "Oracle"
|
||||
case sentinel = "Sentinel"
|
||||
case inventory = "Inventory"
|
||||
case settings = "Settings"
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .dashboard: return "square.grid.2x2"
|
||||
case .oracle: return "message.and.waveform"
|
||||
case .sentinel: return "person.crop.rectangle"
|
||||
case .inventory: return "shippingbox"
|
||||
case .settings: return "gearshape"
|
||||
}
|
||||
}
|
||||
|
||||
var accentColor: Color {
|
||||
switch self {
|
||||
case .dashboard: return VelocityTheme.accent
|
||||
case .oracle: return Color(red: 0.13, green: 0.83, blue: 0.93) // cyan
|
||||
case .sentinel: return Color(red: 0.60, green: 0.57, blue: 0.99) // indigo
|
||||
case .inventory: return VelocityTheme.warning
|
||||
case .settings: return VelocityTheme.mutedFg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
@State private var selectedSection: AppSection? = .dashboard
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView(columnVisibility: .constant(.all)) {
|
||||
sidebarContent
|
||||
} detail: {
|
||||
detailContent
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
}
|
||||
|
||||
// MARK: – Sidebar
|
||||
private var sidebarContent: some View {
|
||||
ZStack {
|
||||
VelocityTheme.sidebarBg.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// App title
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 9)
|
||||
.fill(VelocityTheme.accent.opacity(0.18))
|
||||
.frame(width: 34, height: 34)
|
||||
Image(systemName: "bolt.fill")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("Velocity")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Project Velocity · v1.1")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
Divider()
|
||||
.background(VelocityTheme.borderSubtle)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
// Nav items
|
||||
VStack(spacing: 2) {
|
||||
ForEach(AppSection.allCases) { section in
|
||||
SidebarRow(section: section,
|
||||
isSelected: selectedSection == section)
|
||||
.onTapGesture { selectedSection = section }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
|
||||
Spacer()
|
||||
|
||||
// User footer
|
||||
Divider()
|
||||
.background(VelocityTheme.borderSubtle)
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(VelocityTheme.accent)
|
||||
.frame(width: 32, height: 32)
|
||||
Text("AF")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Ahmed Al-Farsi")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Sales Director")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
.navigationTitle("")
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
}
|
||||
|
||||
// MARK: – Detail
|
||||
private var detailContent: some View {
|
||||
ZStack {
|
||||
VelocityTheme.background.ignoresSafeArea()
|
||||
|
||||
Group {
|
||||
switch selectedSection {
|
||||
case .dashboard: DashboardView()
|
||||
case .oracle: OracleView()
|
||||
case .sentinel: SentinelView()
|
||||
case .inventory: InventoryView()
|
||||
case .settings: SettingsView()
|
||||
case .none: DashboardView()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Sidebar Row
|
||||
private struct SidebarRow: View {
|
||||
let section: AppSection
|
||||
let isSelected: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 11) {
|
||||
Image(systemName: section.systemImage)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(isSelected ? section.accentColor : VelocityTheme.mutedFg)
|
||||
.frame(width: 20)
|
||||
|
||||
Text(section.rawValue)
|
||||
.font(.system(size: 14, weight: isSelected ? .semibold : .regular))
|
||||
.foregroundStyle(isSelected ? VelocityTheme.foreground : VelocityTheme.mutedFg)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 9)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(isSelected ? section.accentColor.opacity(0.12) : .clear)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(isSelected ? section.accentColor.opacity(0.25) : .clear, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
}
|
||||
11
iOS/velocity/velocity/App/VelocityApp.swift
Normal file
11
iOS/velocity/velocity/App/VelocityApp.swift
Normal file
@@ -0,0 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct VelocityApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
iOS/velocity/velocity/Assets.xcassets/Contents.json
Normal file
6
iOS/velocity/velocity/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
92
iOS/velocity/velocity/Core/Math/SunMath.swift
Normal file
92
iOS/velocity/velocity/Core/Math/SunMath.swift
Normal file
@@ -0,0 +1,92 @@
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
|
||||
struct SunPosition {
|
||||
let azimuth: Double // 0...360, degrees clockwise from true north
|
||||
let elevation: Double // -90...90 degrees above horizon
|
||||
}
|
||||
|
||||
enum SunMath {
|
||||
static func calculateSunPosition(date: Date, coordinate: CLLocationCoordinate2D) -> SunPosition {
|
||||
let timezone = TimeZone.current
|
||||
let localOffsetHours = Double(timezone.secondsFromGMT(for: date)) / 3600.0
|
||||
let julianDay = date.julianDay
|
||||
|
||||
let n = julianDay - 2_451_545.0
|
||||
let meanLongitude = normalizeDegrees(280.46 + 0.985_647_4 * n)
|
||||
let meanAnomaly = normalizeDegrees(357.528 + 0.985_600_3 * n)
|
||||
|
||||
let lambda = meanLongitude
|
||||
+ 1.915 * sin(meanAnomaly.radians)
|
||||
+ 0.020 * sin((2.0 * meanAnomaly).radians)
|
||||
let obliquity = 23.439 - 0.000_000_4 * n
|
||||
|
||||
let rightAscension = atan2(
|
||||
cos(obliquity.radians) * sin(lambda.radians),
|
||||
cos(lambda.radians)
|
||||
).degrees
|
||||
let declination = asin(sin(obliquity.radians) * sin(lambda.radians)).degrees
|
||||
|
||||
let utcHours = date.utcHours
|
||||
let lst = normalizeDegrees(100.46 + 0.985_647 * n + coordinate.longitude + 15.0 * utcHours + localOffsetHours)
|
||||
let hourAngle = normalizeDegrees(lst - rightAscension)
|
||||
let signedHourAngle = hourAngle > 180.0 ? hourAngle - 360.0 : hourAngle
|
||||
|
||||
let latitude = coordinate.latitude.radians
|
||||
let declinationRad = declination.radians
|
||||
let hourAngleRad = signedHourAngle.radians
|
||||
|
||||
let elevation = asin(
|
||||
sin(latitude) * sin(declinationRad)
|
||||
+ cos(latitude) * cos(declinationRad) * cos(hourAngleRad)
|
||||
).degrees
|
||||
|
||||
let azimuth = normalizeDegrees(
|
||||
atan2(
|
||||
-sin(hourAngleRad),
|
||||
tan(declinationRad) * cos(latitude) - sin(latitude) * cos(hourAngleRad)
|
||||
).degrees
|
||||
)
|
||||
|
||||
return SunPosition(azimuth: azimuth, elevation: elevation)
|
||||
}
|
||||
|
||||
static func sunPathSamples(for date: Date, coordinate: CLLocationCoordinate2D) -> [Date: SunPosition] {
|
||||
let calendar = Calendar.current
|
||||
let sampleHours = [8, 10, 12, 14, 16]
|
||||
var output: [Date: SunPosition] = [:]
|
||||
|
||||
for hour in sampleHours {
|
||||
if let sampleDate = calendar.date(bySettingHour: hour, minute: 0, second: 0, of: date) {
|
||||
output[sampleDate] = calculateSunPosition(date: sampleDate, coordinate: coordinate)
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private static func normalizeDegrees(_ value: Double) -> Double {
|
||||
let reduced = value.truncatingRemainder(dividingBy: 360.0)
|
||||
return reduced >= 0 ? reduced : reduced + 360.0
|
||||
}
|
||||
}
|
||||
|
||||
private extension Date {
|
||||
var utcHours: Double {
|
||||
let calendar = Calendar(identifier: .gregorian)
|
||||
let comps = calendar.dateComponents(in: TimeZone(secondsFromGMT: 0)!, from: self)
|
||||
let hours = Double(comps.hour ?? 0)
|
||||
let minutes = Double(comps.minute ?? 0)
|
||||
let seconds = Double(comps.second ?? 0)
|
||||
return hours + minutes / 60.0 + seconds / 3600.0
|
||||
}
|
||||
|
||||
var julianDay: Double {
|
||||
let interval = timeIntervalSince1970
|
||||
return (interval / 86_400.0) + 2_440_587.5
|
||||
}
|
||||
}
|
||||
|
||||
private extension Double {
|
||||
var radians: Double { self * .pi / 180.0 }
|
||||
var degrees: Double { self * 180.0 / .pi }
|
||||
}
|
||||
100
iOS/velocity/velocity/Core/Networking/ComfyClient.swift
Normal file
100
iOS/velocity/velocity/Core/Networking/ComfyClient.swift
Normal file
@@ -0,0 +1,100 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
@preconcurrency import Alamofire
|
||||
|
||||
final class ComfyClient {
|
||||
static let shared = ComfyClient()
|
||||
|
||||
private let endpoint = "http://192.168.x.x:8000/dream-weaver"
|
||||
private let session: Session
|
||||
|
||||
private init(session: Session = .default) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
func generateImage(source: UIImage, prompt: String) async throws -> UIImage {
|
||||
let resized = source.resizedSquare(to: 1024)
|
||||
guard let imageData = resized.jpegData(compressionQuality: 0.9) else {
|
||||
throw ComfyClientError.encodingFailed
|
||||
}
|
||||
|
||||
let payload = DreamWeaverRequest(
|
||||
imageBase64: imageData.base64EncodedString(),
|
||||
prompt: prompt
|
||||
)
|
||||
|
||||
let response = try await session.request(
|
||||
endpoint,
|
||||
method: .post,
|
||||
parameters: payload,
|
||||
encoder: JSONParameterEncoder.default,
|
||||
headers: [.contentType("application/json")]
|
||||
)
|
||||
.validate(statusCode: 200..<300)
|
||||
.serializingDecodable(DreamWeaverResponse.self)
|
||||
.value
|
||||
|
||||
guard
|
||||
let data = Data(base64Encoded: response.outputBase64),
|
||||
let generated = UIImage(data: data)
|
||||
else {
|
||||
throw ComfyClientError.decodingFailed
|
||||
}
|
||||
|
||||
return generated
|
||||
}
|
||||
}
|
||||
|
||||
private struct DreamWeaverRequest: Encodable, Sendable {
|
||||
let imageBase64: String
|
||||
let prompt: String
|
||||
}
|
||||
|
||||
private struct DreamWeaverResponse: Decodable, Sendable {
|
||||
let outputBase64: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case outputBase64 = "output_base64"
|
||||
case imageBase64 = "image_base64"
|
||||
case image
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
if let preferred = try container.decodeIfPresent(String.self, forKey: .outputBase64) {
|
||||
outputBase64 = preferred
|
||||
return
|
||||
}
|
||||
if let legacy = try container.decodeIfPresent(String.self, forKey: .imageBase64) {
|
||||
outputBase64 = legacy
|
||||
return
|
||||
}
|
||||
outputBase64 = try container.decode(String.self, forKey: .image)
|
||||
}
|
||||
}
|
||||
|
||||
enum ComfyClientError: Error {
|
||||
case encodingFailed
|
||||
case decodingFailed
|
||||
}
|
||||
|
||||
private extension UIImage {
|
||||
func resizedSquare(to side: CGFloat) -> UIImage {
|
||||
let format = UIGraphicsImageRendererFormat.default()
|
||||
format.scale = 1
|
||||
let renderer = UIGraphicsImageRenderer(size: CGSize(width: side, height: side), format: format)
|
||||
|
||||
return renderer.image { _ in
|
||||
let aspect = size.width / size.height
|
||||
let targetRect: CGRect
|
||||
if aspect > 1 {
|
||||
let width = side * aspect
|
||||
targetRect = CGRect(x: (side - width) / 2, y: 0, width: width, height: side)
|
||||
} else {
|
||||
let height = side / aspect
|
||||
targetRect = CGRect(x: 0, y: (side - height) / 2, width: side, height: height)
|
||||
}
|
||||
draw(in: targetRect)
|
||||
}
|
||||
}
|
||||
}
|
||||
256
iOS/velocity/velocity/Core/State/AppStore.swift
Normal file
256
iOS/velocity/velocity/Core/State/AppStore.swift
Normal file
@@ -0,0 +1,256 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
// MARK: – Data Models
|
||||
|
||||
enum SentimentType: String, CaseIterable {
|
||||
case excited, interested, neutral, confused, disinterested
|
||||
var score: Int {
|
||||
switch self {
|
||||
case .excited: return 100
|
||||
case .interested: return 80
|
||||
case .neutral: return 50
|
||||
case .confused: return 30
|
||||
case .disinterested: return 10
|
||||
}
|
||||
}
|
||||
var emoji: String {
|
||||
switch self {
|
||||
case .excited: return "😃"
|
||||
case .interested: return "🤔"
|
||||
case .neutral: return "😐"
|
||||
case .confused: return "😕"
|
||||
case .disinterested: return "😴"
|
||||
}
|
||||
}
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .excited: return VelocityTheme.success
|
||||
case .interested: return VelocityTheme.accent
|
||||
case .neutral: return VelocityTheme.mutedFg
|
||||
case .confused: return VelocityTheme.warning
|
||||
case .disinterested: return VelocityTheme.danger
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Visitor: Identifiable {
|
||||
let id: String
|
||||
let faceId: String
|
||||
var sentiment: SentimentType
|
||||
var confidence: Double
|
||||
var dwellTime: Int // seconds
|
||||
var zone: String
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
enum LeadSource: String {
|
||||
case whatsapp = "WhatsApp"
|
||||
case walkin = "Walk-in"
|
||||
case website = "Website"
|
||||
}
|
||||
|
||||
enum LeadStatus: String {
|
||||
case hot = "Hot"
|
||||
case engaged = "Engaged"
|
||||
case new = "New"
|
||||
case qualified = "Qualified"
|
||||
case closed = "Closed"
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .hot: return VelocityTheme.danger
|
||||
case .engaged: return VelocityTheme.accent
|
||||
case .new: return VelocityTheme.mutedFg
|
||||
case .qualified: return VelocityTheme.success
|
||||
case .closed: return Color(red: 0.60, green: 0.57, blue: 0.99)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Lead: Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
let phone: String
|
||||
let source: LeadSource
|
||||
var status: LeadStatus
|
||||
var lastMessage: String
|
||||
var lastActive: Date
|
||||
var unreadCount: Int
|
||||
let qualification: String
|
||||
let budget: String
|
||||
let interest: String
|
||||
var initials: String { String(name.split(separator: " ").prefix(2).compactMap(\.first)) }
|
||||
}
|
||||
|
||||
struct ChatMessage: Identifiable {
|
||||
let id: String
|
||||
let sender: String // "user" | "oracle" | "ai"
|
||||
let content: String
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
struct SystemHealth {
|
||||
var cpu: Double // 0–1
|
||||
var gpu: Double
|
||||
var memory: Double
|
||||
}
|
||||
|
||||
struct DashboardMetrics {
|
||||
var activeVisitors: Int
|
||||
var revenue: String
|
||||
var aiJobs: Int
|
||||
var dailyVisitors: Int
|
||||
var sentimentScore: Double // 0–100
|
||||
var systemHealth: SystemHealth
|
||||
}
|
||||
|
||||
// MARK: – Shared Store
|
||||
|
||||
@Observable
|
||||
final class AppStore {
|
||||
|
||||
static let shared = AppStore()
|
||||
private init() { startTimer() }
|
||||
|
||||
// ── Dashboard ─────────────────────────────────────────────────
|
||||
var metrics = DashboardMetrics(
|
||||
activeVisitors: 17,
|
||||
revenue: "$3.2M",
|
||||
aiJobs: 24,
|
||||
dailyVisitors: 128,
|
||||
sentimentScore: 78,
|
||||
systemHealth: SystemHealth(cpu: 0.42, gpu: 0.61, memory: 0.55)
|
||||
)
|
||||
|
||||
var dashboardMessages: [ChatMessage] = [
|
||||
ChatMessage(id: "d0", sender: "ai",
|
||||
content: "Hello, Ahmed. I've analysed the Q3 pipeline. Would you like a refined strategy for the Apex Innovations deal?",
|
||||
timestamp: Date().addingTimeInterval(-300))
|
||||
]
|
||||
var isDashboardThinking = false
|
||||
|
||||
// ── Visitors ──────────────────────────────────────────────────
|
||||
var visitors: [Visitor] = [
|
||||
Visitor(id: "v1", faceId: "face_001", sentiment: .excited, confidence: 0.92, dwellTime: 450, zone: "Penthouse Show", timestamp: Date()),
|
||||
Visitor(id: "v2", faceId: "face_002", sentiment: .interested, confidence: 0.87, dwellTime: 320, zone: "Amenity Deck VR", timestamp: Date()),
|
||||
Visitor(id: "v3", faceId: "face_003", sentiment: .neutral, confidence: 0.78, dwellTime: 180, zone: "Reception", timestamp: Date()),
|
||||
Visitor(id: "v4", faceId: "face_004", sentiment: .confused, confidence: 0.74, dwellTime: 95, zone: "Penthouse Show", timestamp: Date()),
|
||||
Visitor(id: "v5", faceId: "face_005", sentiment: .disinterested, confidence: 0.65, dwellTime: 60, zone: "Gallery", timestamp: Date()),
|
||||
]
|
||||
|
||||
// ── Alerts ────────────────────────────────────────────────────
|
||||
var isAlertActive = false
|
||||
var alertMessage = ""
|
||||
|
||||
func triggerAlert(_ msg: String) {
|
||||
isAlertActive = true
|
||||
alertMessage = msg
|
||||
}
|
||||
func clearAlert() {
|
||||
isAlertActive = false
|
||||
alertMessage = ""
|
||||
}
|
||||
|
||||
// ── Leads (Oracle) ────────────────────────────────────────────
|
||||
var leads: [Lead] = [
|
||||
Lead(id: "1", name: "Mohammed Al-Rashid", phone: "+971 55 123 4567", source: .whatsapp,
|
||||
status: .hot, lastMessage: "Can we schedule a viewing for the penthouse tomorrow?",
|
||||
lastActive: Date().addingTimeInterval(-300), unreadCount: 2,
|
||||
qualification: "whale", budget: "AED 15M+", interest: "Penthouse Suite"),
|
||||
Lead(id: "2", name: "Sarah Chen", phone: "+971 50 987 6543", source: .walkin,
|
||||
status: .engaged, lastMessage: "Thank you for the brochure. I will review with my partner.",
|
||||
lastActive: Date().addingTimeInterval(-1800), unreadCount: 0,
|
||||
qualification: "potential", budget: "AED 5–8M", interest: "2BR Sea View"),
|
||||
Lead(id: "3", name: "James Wilson", phone: "+971 52 456 7890", source: .website,
|
||||
status: .new, lastMessage: "Interested in investment opportunities.",
|
||||
lastActive: Date().addingTimeInterval(-7200), unreadCount: 1,
|
||||
qualification: "potential", budget: "AED 3–5M", interest: "1BR Investment"),
|
||||
Lead(id: "4", name: "Fatima Hassan", phone: "+971 54 321 0987", source: .whatsapp,
|
||||
status: .qualified,lastMessage: "What are the payment plan options?",
|
||||
lastActive: Date().addingTimeInterval(-14400), unreadCount: 0,
|
||||
qualification: "whale", budget: "AED 12M+", interest: "3BR + Maid"),
|
||||
Lead(id: "5", name: "David Kumar", phone: "+971 56 789 0123", source: .walkin,
|
||||
status: .closed, lastMessage: "Contract signed. Thank you!",
|
||||
lastActive: Date().addingTimeInterval(-86400), unreadCount: 0,
|
||||
qualification: "whale", budget: "AED 20M", interest: "Full Floor"),
|
||||
]
|
||||
|
||||
var messages: [String: [ChatMessage]] = [
|
||||
"1": [
|
||||
ChatMessage(id: "m1", sender: "user", content: "Hi, I am interested in the penthouse units.",
|
||||
timestamp: Date().addingTimeInterval(-7200)),
|
||||
ChatMessage(id: "m2", sender: "oracle",
|
||||
content: "Welcome! Our penthouse collection features 4 exclusive units with panoramic sea views. Prices start at AED 15M.",
|
||||
timestamp: Date().addingTimeInterval(-7200 + 30)),
|
||||
ChatMessage(id: "m3", sender: "user", content: "Can we schedule a viewing tomorrow?",
|
||||
timestamp: Date().addingTimeInterval(-300)),
|
||||
],
|
||||
"2": [
|
||||
ChatMessage(id: "m4", sender: "oracle",
|
||||
content: "Hello Sarah! Here is the digital brochure for the 2-bedroom units we discussed.",
|
||||
timestamp: Date().addingTimeInterval(-14400)),
|
||||
ChatMessage(id: "m5", sender: "user", content: "Thank you. I will review with my partner.",
|
||||
timestamp: Date().addingTimeInterval(-1800)),
|
||||
],
|
||||
]
|
||||
|
||||
var activeLeadId: String? = "1"
|
||||
var isOracleThinking = false
|
||||
|
||||
func addDashboardMessage(sender: String, content: String) {
|
||||
let msg = ChatMessage(id: UUID().uuidString, sender: sender, content: content, timestamp: Date())
|
||||
dashboardMessages.append(msg)
|
||||
}
|
||||
|
||||
func addOracleMessage(leadId: String, sender: String, content: String) {
|
||||
let msg = ChatMessage(id: UUID().uuidString, sender: sender, content: content, timestamp: Date())
|
||||
if messages[leadId] == nil { messages[leadId] = [] }
|
||||
messages[leadId]!.append(msg)
|
||||
}
|
||||
|
||||
// ── Live ticker ───────────────────────────────────────────────
|
||||
private var timerTask: AnyCancellable?
|
||||
private var alertTask: DispatchWorkItem?
|
||||
|
||||
private func startTimer() {
|
||||
timerTask = Timer.publish(every: 5, on: .main, in: .common)
|
||||
.autoconnect()
|
||||
.sink { [weak self] _ in self?.tick() }
|
||||
}
|
||||
|
||||
private func tick() {
|
||||
// jitter visitor count ±1
|
||||
let delta = Int.random(in: -1...1)
|
||||
metrics.activeVisitors = max(10, metrics.activeVisitors + delta)
|
||||
|
||||
// jitter sentiment ±2
|
||||
let sDelta = Double.random(in: -2...2)
|
||||
metrics.sentimentScore = min(100, max(40, metrics.sentimentScore + sDelta))
|
||||
|
||||
// jitter system health
|
||||
metrics.systemHealth.cpu = Double.random(in: 0.30...0.65)
|
||||
metrics.systemHealth.gpu = Double.random(in: 0.45...0.75)
|
||||
metrics.systemHealth.memory = Double.random(in: 0.40...0.70)
|
||||
|
||||
// Random alert (same 10% chance as WebOS every tick)
|
||||
if !isAlertActive && Double.random(in: 0...1) > 0.85 {
|
||||
triggerAlert("Confusion detected in Zone B – Penthouse Gallery")
|
||||
let work = DispatchWorkItem { [weak self] in self?.clearAlert() }
|
||||
alertTask = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: work)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Helpers
|
||||
|
||||
extension Date {
|
||||
var relativeShort: String {
|
||||
let diff = Int(Date().timeIntervalSince(self))
|
||||
if diff < 60 { return "now" }
|
||||
if diff < 3600 { return "\(diff / 60)m ago" }
|
||||
if diff < 86400 { return "\(diff / 3600)h ago" }
|
||||
return "\(diff / 86400)d ago"
|
||||
}
|
||||
}
|
||||
17
iOS/velocity/velocity/Core/UI/GlassBlurView.swift
Normal file
17
iOS/velocity/velocity/Core/UI/GlassBlurView.swift
Normal file
@@ -0,0 +1,17 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GlassBlurView: UIViewRepresentable {
|
||||
let style: UIBlurEffect.Style
|
||||
|
||||
init(style: UIBlurEffect.Style = .systemUltraThinMaterial) {
|
||||
self.style = style
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> UIVisualEffectView {
|
||||
UIVisualEffectView(effect: UIBlurEffect(style: style))
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIVisualEffectView, context: Context) {
|
||||
uiView.effect = UIBlurEffect(style: style)
|
||||
}
|
||||
}
|
||||
60
iOS/velocity/velocity/Core/UI/VelocityTheme.swift
Normal file
60
iOS/velocity/velocity/Core/UI/VelocityTheme.swift
Normal file
@@ -0,0 +1,60 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Design Tokens matching the WebOS dark interface
|
||||
enum VelocityTheme {
|
||||
|
||||
// ── Backgrounds ──────────────────────────────────
|
||||
/// True black app background
|
||||
static let background = Color(red: 0.00, green: 0.00, blue: 0.00)
|
||||
/// Dark surface (#131418)
|
||||
static let surface = Color(red: 0.074, green: 0.078, blue: 0.094)
|
||||
/// Slightly lighter surface (#181b20)
|
||||
static let surface2 = Color(red: 0.095, green: 0.106, blue: 0.125)
|
||||
/// Card surface (#22262e)
|
||||
static let surface3 = Color(red: 0.133, green: 0.149, blue: 0.180)
|
||||
/// Sidebar background (#0B0D10)
|
||||
static let sidebarBg = Color(red: 0.043, green: 0.051, blue: 0.063)
|
||||
|
||||
// ── Foreground ────────────────────────────────────
|
||||
static let foreground = Color(white: 0.96)
|
||||
static let mutedFg = Color(red: 0.580, green: 0.620, blue: 0.710)
|
||||
static let subtleFg = Color(red: 0.35, green: 0.38, blue: 0.44)
|
||||
|
||||
// ── Accent: Blue (#3b82f6) ────────────────────────
|
||||
static let accent = Color(red: 0.231, green: 0.510, blue: 0.965)
|
||||
static let accentDim = Color(red: 0.160, green: 0.388, blue: 0.820)
|
||||
static let accentSubtle = Color(red: 0.231, green: 0.510, blue: 0.965).opacity(0.15)
|
||||
|
||||
// ── Semantic ──────────────────────────────────────
|
||||
static let success = Color(red: 0.290, green: 0.780, blue: 0.290)
|
||||
static let warning = Color(red: 0.980, green: 0.745, blue: 0.141)
|
||||
static let danger = Color(red: 0.973, green: 0.267, blue: 0.267)
|
||||
|
||||
// ── Borders ───────────────────────────────────────
|
||||
static let borderSubtle = Color.white.opacity(0.07)
|
||||
static let borderAccent = Color(red: 0.231, green: 0.510, blue: 0.965).opacity(0.18)
|
||||
}
|
||||
|
||||
// MARK: - Glass card modifier
|
||||
struct GlassCard: ViewModifier {
|
||||
var cornerRadius: CGFloat = 16
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071).opacity(0.82))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.stroke(VelocityTheme.borderAccent, lineWidth: 1)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.55), radius: 16, y: 4)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func glassCard(cornerRadius: CGFloat = 16) -> some View {
|
||||
self.modifier(GlassCard(cornerRadius: cornerRadius))
|
||||
}
|
||||
}
|
||||
442
iOS/velocity/velocity/Features/Dashboard/DashboardView.swift
Normal file
442
iOS/velocity/velocity/Features/Dashboard/DashboardView.swift
Normal file
@@ -0,0 +1,442 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DashboardView: View {
|
||||
private var store: AppStore { AppStore.shared }
|
||||
@State private var chatInput = ""
|
||||
private let columns = [GridItem(.adaptive(minimum: 220), spacing: 14)]
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
pageHeader
|
||||
|
||||
// KPI Grid — live from store
|
||||
LazyVGrid(columns: columns, spacing: 14) {
|
||||
LiveKPICard(
|
||||
title: "Visitors",
|
||||
value: "\(store.metrics.activeVisitors)",
|
||||
subtitle: "Active now",
|
||||
icon: "person.2",
|
||||
accentColor: VelocityTheme.accent,
|
||||
glowColor: VelocityTheme.accent.opacity(0.22),
|
||||
badge: "LIVE"
|
||||
)
|
||||
LiveKPICard(
|
||||
title: "Revenue",
|
||||
value: store.metrics.revenue,
|
||||
subtitle: "30-day forecast",
|
||||
icon: "chart.line.uptrend.xyaxis",
|
||||
accentColor: Color(red: 0.13, green: 0.83, blue: 0.93),
|
||||
glowColor: Color(red: 0.13, green: 0.83, blue: 0.93).opacity(0.18)
|
||||
)
|
||||
LiveKPICard(
|
||||
title: "AI Jobs",
|
||||
value: "\(store.metrics.aiJobs)",
|
||||
subtitle: "Queue depth",
|
||||
icon: "cpu",
|
||||
accentColor: Color(red: 0.60, green: 0.57, blue: 0.99),
|
||||
glowColor: Color(red: 0.60, green: 0.57, blue: 0.99).opacity(0.20)
|
||||
)
|
||||
LiveKPICard(
|
||||
title: "Listings",
|
||||
value: "\(store.metrics.dailyVisitors)",
|
||||
subtitle: "Active units",
|
||||
icon: "building.2",
|
||||
accentColor: VelocityTheme.success,
|
||||
glowColor: VelocityTheme.success.opacity(0.18)
|
||||
)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.4), value: store.metrics.activeVisitors)
|
||||
|
||||
// Sentiment Gauge
|
||||
sentimentGauge
|
||||
|
||||
// System Health
|
||||
systemHealthPanel
|
||||
|
||||
// AI Chat Widget
|
||||
aiChatWidget
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
.background(VelocityTheme.background)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
|
||||
// MARK: – Page Header
|
||||
private var pageHeader: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Dashboard")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Project Velocity · v.1.1")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 5) {
|
||||
Circle()
|
||||
.fill(VelocityTheme.success)
|
||||
.frame(width: 7, height: 7)
|
||||
Text("Live")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.success)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Sentiment Gauge
|
||||
private var sentimentGauge: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Image(systemName: "waveform.path.ecg")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
Text("Sentiment Thermometer")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
Text("Showroom Vibe")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
let label = store.metrics.sentimentScore >= 70 ? "Excellent" :
|
||||
store.metrics.sentimentScore >= 50 ? "Good" : "Needs Attention"
|
||||
let labelColor: Color = store.metrics.sentimentScore >= 70 ? VelocityTheme.success :
|
||||
store.metrics.sentimentScore >= 50 ? VelocityTheme.warning : VelocityTheme.danger
|
||||
Text(label)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(labelColor)
|
||||
}
|
||||
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.fill(Color.white.opacity(0.05))
|
||||
.frame(height: 26)
|
||||
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color(red: 0.11, green: 0.30, blue: 0.86),
|
||||
VelocityTheme.accent,
|
||||
Color(red: 0.38, green: 0.65, blue: 0.98)],
|
||||
startPoint: .leading, endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.frame(width: geo.size.width * (store.metrics.sentimentScore / 100), height: 26)
|
||||
.shadow(color: VelocityTheme.accent.opacity(0.6), radius: 6)
|
||||
.animation(.easeInOut(duration: 0.8), value: store.metrics.sentimentScore)
|
||||
|
||||
Text("\(Int(store.metrics.sentimentScore))%")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.frame(height: 26)
|
||||
}
|
||||
.padding(20)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(VelocityTheme.borderAccent, lineWidth: 1))
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: – System Health
|
||||
private var systemHealthPanel: some View {
|
||||
let gauges: [(label: String, value: Double, color: Color)] = [
|
||||
("CPU", store.metrics.systemHealth.cpu, VelocityTheme.accent),
|
||||
("GPU", store.metrics.systemHealth.gpu, Color(red: 0.50, green: 0.56, blue: 0.97)),
|
||||
("Memory", store.metrics.systemHealth.memory, Color(red: 0.13, green: 0.83, blue: 0.93)),
|
||||
]
|
||||
|
||||
return VStack(alignment: .leading, spacing: 14) {
|
||||
HStack {
|
||||
Image(systemName: "cpu").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent)
|
||||
Text("System Health")
|
||||
.font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
HStack(spacing: 4) {
|
||||
Circle().fill(VelocityTheme.success).frame(width: 5, height: 5)
|
||||
Text("Optimal").font(.system(size: 11, weight: .medium)).foregroundStyle(VelocityTheme.success)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 16) {
|
||||
ForEach(gauges, id: \.label) { g in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(g.label).font(.system(size: 10, weight: .medium)).tracking(0.8)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Spacer()
|
||||
Text("\(Int(g.value * 100))%").font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.05)).frame(height: 5)
|
||||
RoundedRectangle(cornerRadius: 3).fill(g.color)
|
||||
.frame(width: geo.size.width * g.value, height: 5)
|
||||
.shadow(color: g.color.opacity(0.6), radius: 4)
|
||||
.animation(.easeInOut(duration: 0.6), value: g.value)
|
||||
}
|
||||
}
|
||||
.frame(height: 5)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(VelocityTheme.borderAccent, lineWidth: 1))
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: – AI Chat Widget
|
||||
private var aiChatWidget: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
Circle().fill(VelocityTheme.accent.opacity(0.18)).frame(width: 34, height: 34)
|
||||
Image(systemName: "sparkles").font(.system(size: 14)).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 5) {
|
||||
Text("AI Onboard").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Image(systemName: "staroflife.fill").font(.system(size: 7)).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
Circle().fill(VelocityTheme.success).frame(width: 5, height: 5)
|
||||
Text("Online").font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(16)
|
||||
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
|
||||
// Messages
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
ForEach(store.dashboardMessages) { msg in
|
||||
ChatBubble(message: msg)
|
||||
.id(msg.id)
|
||||
}
|
||||
if store.isDashboardThinking {
|
||||
TypingIndicator()
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.frame(height: 240)
|
||||
.onChange(of: store.dashboardMessages.count) {
|
||||
if let last = store.dashboardMessages.last {
|
||||
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
|
||||
}
|
||||
}
|
||||
.onChange(of: store.isDashboardThinking) {
|
||||
if store.isDashboardThinking {
|
||||
withAnimation { proxy.scrollTo("typing", anchor: .bottom) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
|
||||
// Input
|
||||
HStack(spacing: 10) {
|
||||
TextField("Ask AI assistant...", text: $chatInput)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.tint(VelocityTheme.accent)
|
||||
.onSubmit { sendDashboardMessage() }
|
||||
|
||||
Button(action: sendDashboardMessage) {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(chatInput.isEmpty ? VelocityTheme.mutedFg : VelocityTheme.accent)
|
||||
}
|
||||
.disabled(chatInput.isEmpty || store.isDashboardThinking)
|
||||
}
|
||||
.padding(14)
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(VelocityTheme.borderAccent, lineWidth: 1))
|
||||
)
|
||||
}
|
||||
|
||||
private func sendDashboardMessage() {
|
||||
let text = chatInput.trimmingCharacters(in: .whitespaces)
|
||||
guard !text.isEmpty else { return }
|
||||
chatInput = ""
|
||||
store.addDashboardMessage(sender: "user", content: text)
|
||||
store.isDashboardThinking = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||||
store.isDashboardThinking = false
|
||||
store.addDashboardMessage(
|
||||
sender: "ai",
|
||||
content: dashboardAIResponse(for: text)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func dashboardAIResponse(for prompt: String) -> String {
|
||||
let p = prompt.lowercased()
|
||||
if p.contains("penthouse") || p.contains("apex") {
|
||||
return "Apex Innovations probability score is now at 85%. I recommend scheduling a closing meeting next Tuesday — sentiment analysis shows high engagement."
|
||||
} else if p.contains("visitor") || p.contains("traffic") {
|
||||
return "Currently \(store.metrics.activeVisitors) active visitors. Zone B (Penthouse Gallery) is generating the highest dwell time today — average 8 minutes."
|
||||
} else if p.contains("revenue") || p.contains("deal") {
|
||||
return "Pipeline value stands at \(store.metrics.revenue) for the 30-day forecast. 3 deals are in final negotiation — I recommend prioritising Mohammed Al-Rashid."
|
||||
} else if p.contains("sentiment") {
|
||||
return "Showroom sentiment is at \(Int(store.metrics.sentimentScore))% — above the excellent threshold. Visitors in Zone D (Reception) show the highest satisfaction scores."
|
||||
}
|
||||
return "I've analysed the current data. Revenue is tracking at \(store.metrics.revenue) with \(store.metrics.activeVisitors) active visitors. Shall I prepare a detailed pipeline report?"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – KPI Card (live-bound)
|
||||
private struct LiveKPICard: View {
|
||||
let title: String
|
||||
let value: String
|
||||
let subtitle: String
|
||||
let icon: String
|
||||
let accentColor: Color
|
||||
let glowColor: Color
|
||||
var badge: String? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8).fill(accentColor.opacity(0.12)).frame(width: 32, height: 32)
|
||||
Image(systemName: icon).font(.system(size: 14, weight: .medium)).foregroundStyle(accentColor)
|
||||
}
|
||||
Spacer()
|
||||
if let badge {
|
||||
HStack(spacing: 4) {
|
||||
Circle().fill(VelocityTheme.success).frame(width: 5, height: 5)
|
||||
Text(badge).font(.system(size: 9, weight: .semibold)).foregroundStyle(VelocityTheme.success)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 20)
|
||||
|
||||
Text(title.uppercased())
|
||||
.font(.system(size: 10, weight: .medium)).tracking(1.2)
|
||||
.foregroundStyle(VelocityTheme.mutedFg).padding(.bottom, 4)
|
||||
|
||||
Text(value)
|
||||
.font(.system(size: 34, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.contentTransition(.numericText())
|
||||
.minimumScaleFactor(0.7).lineLimit(1).padding(.bottom, 4)
|
||||
|
||||
Text(subtitle).font(.system(size: 11)).foregroundStyle(VelocityTheme.subtleFg)
|
||||
}
|
||||
.padding(20)
|
||||
.frame(maxWidth: .infinity, minHeight: 148, alignment: .leading)
|
||||
.background(
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
RoundedRectangle(cornerRadius: 16).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
Ellipse().fill(glowColor).frame(width: 120, height: 90).blur(radius: 28).offset(x: 20, y: 20)
|
||||
VStack {
|
||||
Rectangle()
|
||||
.fill(LinearGradient(colors: [.clear, .white.opacity(0.10), .clear], startPoint: .leading, endPoint: .trailing))
|
||||
.frame(height: 1)
|
||||
Spacer()
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(VelocityTheme.borderAccent, lineWidth: 1))
|
||||
.shadow(color: .black.opacity(0.55), radius: 16, y: 4)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Chat Bubble
|
||||
private struct ChatBubble: View {
|
||||
let message: ChatMessage
|
||||
private var isUser: Bool { message.sender == "user" }
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
if isUser { Spacer(minLength: 40) }
|
||||
|
||||
if !isUser {
|
||||
ZStack {
|
||||
Circle().fill(VelocityTheme.accent.opacity(0.18)).frame(width: 26, height: 26)
|
||||
Image(systemName: "sparkles").font(.system(size: 10)).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
}
|
||||
|
||||
Text(message.content)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(isUser ? .white : VelocityTheme.foreground)
|
||||
.padding(.horizontal, 12).padding(.vertical, 9)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: isUser ? 14 : 14)
|
||||
.fill(isUser
|
||||
? VelocityTheme.accent.opacity(0.85)
|
||||
: Color.white.opacity(0.06))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(isUser ? .clear : Color.white.opacity(0.10), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
|
||||
if isUser {
|
||||
ZStack {
|
||||
Circle().fill(Color(red: 0.08, green: 0.10, blue: 0.18)).frame(width: 26, height: 26)
|
||||
Text("A").font(.system(size: 10, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
}
|
||||
if !isUser { Spacer(minLength: 40) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Typing Indicator
|
||||
private struct TypingIndicator: View {
|
||||
@State private var phase = 0
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
ZStack {
|
||||
Circle().fill(VelocityTheme.accent.opacity(0.18)).frame(width: 26, height: 26)
|
||||
Image(systemName: "sparkles").font(.system(size: 10)).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
ForEach(0..<3, id: \.self) { i in
|
||||
Circle()
|
||||
.fill(VelocityTheme.mutedFg)
|
||||
.frame(width: 6, height: 6)
|
||||
.scaleEffect(phase == i ? 1.4 : 0.8)
|
||||
.animation(.easeInOut(duration: 0.4).repeatForever().delay(Double(i) * 0.15), value: phase)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.vertical, 12)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.06))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.white.opacity(0.10), lineWidth: 1)))
|
||||
Spacer(minLength: 40)
|
||||
}
|
||||
.id("typing")
|
||||
.onAppear {
|
||||
withAnimation { phase = 1 }
|
||||
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
|
||||
phase = (phase + 1) % 3
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
118
iOS/velocity/velocity/Features/Inventory/ARSunOverlayView.swift
Normal file
118
iOS/velocity/velocity/Features/Inventory/ARSunOverlayView.swift
Normal file
@@ -0,0 +1,118 @@
|
||||
import ARKit
|
||||
import CoreLocation
|
||||
import CoreMotion
|
||||
import SceneKit
|
||||
import SwiftUI
|
||||
|
||||
struct ARSunOverlayView: UIViewRepresentable {
|
||||
@Binding var sunNodesReady: Bool
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(sunNodesReady: $sunNodesReady)
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> ARSCNView {
|
||||
let view = ARSCNView(frame: .zero)
|
||||
view.delegate = context.coordinator
|
||||
view.scene = SCNScene()
|
||||
view.automaticallyUpdatesLighting = true
|
||||
|
||||
let config = ARWorldTrackingConfiguration()
|
||||
config.worldAlignment = .gravityAndHeading
|
||||
view.session.run(config)
|
||||
|
||||
context.coordinator.attach(to: view)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: ARSCNView, context: Context) {}
|
||||
|
||||
static func dismantleUIView(_ uiView: ARSCNView, coordinator: Coordinator) {
|
||||
uiView.session.pause()
|
||||
coordinator.stop()
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, ARSCNViewDelegate, CLLocationManagerDelegate {
|
||||
private let locationManager = CLLocationManager()
|
||||
private let motionManager = CMMotionManager()
|
||||
private weak var sceneView: ARSCNView?
|
||||
private var heading: CLLocationDirection = 0
|
||||
private var coordinate: CLLocationCoordinate2D?
|
||||
@Binding private var sunNodesReady: Bool
|
||||
|
||||
init(sunNodesReady: Binding<Bool>) {
|
||||
_sunNodesReady = sunNodesReady
|
||||
super.init()
|
||||
locationManager.delegate = self
|
||||
locationManager.desiredAccuracy = kCLLocationAccuracyBest
|
||||
locationManager.headingFilter = 1
|
||||
locationManager.requestWhenInUseAuthorization()
|
||||
locationManager.startUpdatingLocation()
|
||||
locationManager.startUpdatingHeading()
|
||||
startMotion()
|
||||
}
|
||||
|
||||
func attach(to sceneView: ARSCNView) {
|
||||
self.sceneView = sceneView
|
||||
addSunPathNodesIfPossible()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
motionManager.stopDeviceMotionUpdates()
|
||||
locationManager.stopUpdatingHeading()
|
||||
locationManager.stopUpdatingLocation()
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
guard coordinate == nil, let location = locations.last else { return }
|
||||
coordinate = location.coordinate
|
||||
addSunPathNodesIfPossible()
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
|
||||
heading = newHeading.trueHeading > 0 ? newHeading.trueHeading : newHeading.magneticHeading
|
||||
addSunPathNodesIfPossible()
|
||||
}
|
||||
|
||||
private func startMotion() {
|
||||
guard motionManager.isDeviceMotionAvailable else { return }
|
||||
motionManager.deviceMotionUpdateInterval = 0.1
|
||||
motionManager.startDeviceMotionUpdates()
|
||||
}
|
||||
|
||||
private func addSunPathNodesIfPossible() {
|
||||
guard
|
||||
let sceneView,
|
||||
let coordinate,
|
||||
!sunNodesReady
|
||||
else { return }
|
||||
|
||||
let samples = SunMath.sunPathSamples(for: Date(), coordinate: coordinate)
|
||||
let sorted = samples.sorted { $0.key < $1.key }
|
||||
let root = SCNNode()
|
||||
let northOffset = (heading).radians
|
||||
let radius: Float = 1.8
|
||||
|
||||
for (_, pos) in sorted {
|
||||
let elevation = Float(pos.elevation.radians)
|
||||
let azimuth = Float((pos.azimuth).radians) - Float(northOffset)
|
||||
let x = radius * cos(elevation) * sin(azimuth)
|
||||
let y = radius * sin(elevation)
|
||||
let z = -radius * cos(elevation) * cos(azimuth)
|
||||
|
||||
let sphere = SCNSphere(radius: 0.03)
|
||||
sphere.firstMaterial?.diffuse.contents = UIColor.systemYellow
|
||||
let node = SCNNode(geometry: sphere)
|
||||
node.position = SCNVector3(x, y, z)
|
||||
root.addChildNode(node)
|
||||
}
|
||||
|
||||
sceneView.scene.rootNode.addChildNode(root)
|
||||
sunNodesReady = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension Double {
|
||||
var radians: Double { self * .pi / 180.0 }
|
||||
}
|
||||
439
iOS/velocity/velocity/Features/Inventory/InventoryView.swift
Normal file
439
iOS/velocity/velocity/Features/Inventory/InventoryView.swift
Normal file
@@ -0,0 +1,439 @@
|
||||
import AVFoundation
|
||||
import Observation
|
||||
import SceneKit
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@Observable
|
||||
final class InventoryStore {
|
||||
enum Mode: String, CaseIterable, Identifiable {
|
||||
case sunseeker = "Sunseeker"
|
||||
case dreamWeaver = "Dream Weaver"
|
||||
case dollhouse = "Dollhouse"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
var mode: Mode = .sunseeker
|
||||
var selectedPrompt: String = "Modern Islamic"
|
||||
var sourceImage: UIImage?
|
||||
var generatedImage: UIImage?
|
||||
var isProcessing: Bool = false
|
||||
var sunNodesReady: Bool = false
|
||||
var dollhouseHour: Double = 12
|
||||
|
||||
let prompts = ["Modern Islamic", "Minimalist", "Night Mode"]
|
||||
}
|
||||
|
||||
struct InventoryView: View {
|
||||
@State private var store = InventoryStore()
|
||||
@State private var showCamera = false
|
||||
@State private var sliderTickHour = 12
|
||||
private let haptics = UIImpactFeedbackGenerator(style: .light)
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Page header
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Inventory")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Sunseeker · Dream Weaver · Dollhouse")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 20)
|
||||
|
||||
Picker("Mode", selection: $store.mode) {
|
||||
ForEach(InventoryStore.Mode.allCases) { mode in
|
||||
Text(mode.rawValue).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 12)
|
||||
|
||||
Group {
|
||||
switch store.mode {
|
||||
case .sunseeker:
|
||||
#if targetEnvironment(simulator)
|
||||
ZStack {
|
||||
VStack(spacing: 14) {
|
||||
Image(systemName: "camera.metering.unknown")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text("AR Not Available in Simulator")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Sunseeker requires a real device with a camera and compass. Run on iPhone or iPad to use this feature.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(24)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(VelocityTheme.borderAccent, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
#else
|
||||
SunseekerPanel(sunNodesReady: $store.sunNodesReady)
|
||||
#endif
|
||||
case .dreamWeaver:
|
||||
DreamWeaverPanel(
|
||||
sourceImage: $store.sourceImage,
|
||||
generatedImage: $store.generatedImage,
|
||||
selectedPrompt: $store.selectedPrompt,
|
||||
isProcessing: $store.isProcessing,
|
||||
prompts: store.prompts,
|
||||
showCamera: $showCamera
|
||||
)
|
||||
case .dollhouse:
|
||||
DollhousePanel(hour: $store.dollhouseHour, tickHour: $sliderTickHour, haptics: haptics)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 20)
|
||||
.animation(.easeInOut(duration: 0.25), value: store.mode)
|
||||
}
|
||||
.background(VelocityTheme.background)
|
||||
.onAppear {
|
||||
// Dark-theme the segmented control
|
||||
UISegmentedControl.appearance().selectedSegmentTintColor = UIColor(
|
||||
red: 0.231, green: 0.510, blue: 0.965, alpha: 0.85)
|
||||
UISegmentedControl.appearance().setTitleTextAttributes(
|
||||
[.foregroundColor: UIColor.white], for: .selected)
|
||||
UISegmentedControl.appearance().setTitleTextAttributes(
|
||||
[.foregroundColor: UIColor(white: 0.62, alpha: 1)], for: .normal)
|
||||
UISegmentedControl.appearance().backgroundColor = UIColor(
|
||||
red: 0.031, green: 0.039, blue: 0.071, alpha: 1)
|
||||
}
|
||||
.sheet(isPresented: $showCamera) {
|
||||
CameraPicker(image: $store.sourceImage, isPresented: $showCamera)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SunseekerPanel: View {
|
||||
@Binding var sunNodesReady: Bool
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
ARSunOverlayView(sunNodesReady: $sunNodesReady)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
|
||||
DashedSunLine()
|
||||
.stroke(Color.yellow.opacity(0.9), style: StrokeStyle(lineWidth: 3, dash: [10, 8]))
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 80)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Sunseeker")
|
||||
.font(.headline)
|
||||
Text("Point the iPad toward windows to inspect yearly sun-entry path.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(14)
|
||||
.background {
|
||||
GlassBlurView(style: .systemThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DreamWeaverPanel: View {
|
||||
@Binding var sourceImage: UIImage?
|
||||
@Binding var generatedImage: UIImage?
|
||||
@Binding var selectedPrompt: String
|
||||
@Binding var isProcessing: Bool
|
||||
let prompts: [String]
|
||||
@Binding var showCamera: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.fill(Color.black.opacity(0.9))
|
||||
|
||||
if let sourceImage {
|
||||
Image(uiImage: sourceImage)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.padding(12)
|
||||
} else {
|
||||
ContentUnavailableView("No Capture", systemImage: "camera.viewfinder", description: Text("Tap Capture to snap a room."))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
if let generatedImage {
|
||||
Image(uiImage: generatedImage)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.padding(12)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
if isProcessing {
|
||||
ProcessingOverlay()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 420)
|
||||
.animation(.easeInOut(duration: 0.35), value: generatedImage)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(prompts, id: \.self) { prompt in
|
||||
Text(prompt)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(prompt == selectedPrompt ? Color.yellow.opacity(0.85) : Color.white.opacity(0.12))
|
||||
)
|
||||
.onTapGesture { selectedPrompt = prompt }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button("Capture") {
|
||||
showCamera = true
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button("Reimagine") {
|
||||
Task { await generate() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(sourceImage == nil || isProcessing)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background {
|
||||
GlassBlurView(style: .systemThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func generate() async {
|
||||
guard let sourceImage, !isProcessing else { return }
|
||||
isProcessing = true
|
||||
do {
|
||||
let result = try await ComfyClient.shared.generateImage(source: sourceImage, prompt: selectedPrompt)
|
||||
withAnimation(.easeInOut(duration: 0.4)) {
|
||||
generatedImage = result
|
||||
}
|
||||
} catch {
|
||||
print("Dream Weaver error: \(error)")
|
||||
}
|
||||
isProcessing = false
|
||||
}
|
||||
}
|
||||
|
||||
private struct DollhousePanel: View {
|
||||
@Binding var hour: Double
|
||||
@Binding var tickHour: Int
|
||||
let haptics: UIImpactFeedbackGenerator
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
SceneKitDollhouseView(hour: $hour)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
.frame(maxWidth: .infinity, minHeight: 460)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(String(format: "Time: %02d:00", Int(hour.rounded())))
|
||||
.font(.headline)
|
||||
Slider(value: $hour, in: 0...24, step: 0.25)
|
||||
.onChange(of: hour) { _, newValue in
|
||||
let rounded = Int(newValue.rounded())
|
||||
if rounded != tickHour {
|
||||
tickHour = rounded
|
||||
haptics.impactOccurred(intensity: 0.7)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.background {
|
||||
GlassBlurView(style: .systemThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SceneKitDollhouseView: UIViewRepresentable {
|
||||
@Binding var hour: Double
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> SCNView {
|
||||
let view = SCNView()
|
||||
view.scene = context.coordinator.scene
|
||||
view.autoenablesDefaultLighting = false
|
||||
view.allowsCameraControl = true
|
||||
view.backgroundColor = UIColor.systemBackground
|
||||
context.coordinator.setupScene()
|
||||
context.coordinator.updateSunLight(hour: hour)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: SCNView, context: Context) {
|
||||
context.coordinator.updateSunLight(hour: hour)
|
||||
}
|
||||
|
||||
final class Coordinator {
|
||||
let scene = SCNScene()
|
||||
private let sunNode = SCNNode()
|
||||
|
||||
func setupScene() {
|
||||
if let modelScene = SCNScene(named: "Building.usdz") ?? SCNScene(named: "Building.scn") {
|
||||
let container = SCNNode()
|
||||
for child in modelScene.rootNode.childNodes {
|
||||
container.addChildNode(child.clone())
|
||||
}
|
||||
scene.rootNode.addChildNode(container)
|
||||
} else {
|
||||
let fallback = SCNFloor()
|
||||
fallback.firstMaterial?.diffuse.contents = UIColor.secondarySystemBackground
|
||||
scene.rootNode.addChildNode(SCNNode(geometry: fallback))
|
||||
}
|
||||
|
||||
let camera = SCNCamera()
|
||||
let cameraNode = SCNNode()
|
||||
cameraNode.camera = camera
|
||||
cameraNode.position = SCNVector3(0, 4, 10)
|
||||
scene.rootNode.addChildNode(cameraNode)
|
||||
|
||||
let light = SCNLight()
|
||||
light.type = .directional
|
||||
light.intensity = 1_200
|
||||
light.castsShadow = true
|
||||
sunNode.light = light
|
||||
scene.rootNode.addChildNode(sunNode)
|
||||
|
||||
let ambient = SCNLight()
|
||||
ambient.type = .ambient
|
||||
ambient.intensity = 200
|
||||
let ambientNode = SCNNode()
|
||||
ambientNode.light = ambient
|
||||
scene.rootNode.addChildNode(ambientNode)
|
||||
}
|
||||
|
||||
func updateSunLight(hour: Double) {
|
||||
let normalized = (hour / 24.0) * (2 * Double.pi)
|
||||
let x = Float(cos(normalized) * 8.0)
|
||||
let y = Float(max(sin(normalized) * 8.0, 1.0))
|
||||
let z = Float(sin(normalized + .pi / 3) * 6.0)
|
||||
sunNode.position = SCNVector3(x, y, z)
|
||||
sunNode.look(at: SCNVector3(0, 0, 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProcessingOverlay: View {
|
||||
@State private var animate = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(.black.opacity(0.45))
|
||||
|
||||
Text("AI Processing...")
|
||||
.font(.headline.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
.background {
|
||||
GlassBlurView(style: .systemUltraThinMaterialDark)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.overlay(
|
||||
Rectangle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [.clear, .white.opacity(0.6), .clear],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.rotationEffect(.degrees(18))
|
||||
.offset(x: animate ? 160 : -160)
|
||||
.animation(.linear(duration: 1.2).repeatForever(autoreverses: false), value: animate)
|
||||
.blendMode(.screen)
|
||||
.mask(Capsule().frame(height: 44))
|
||||
)
|
||||
}
|
||||
.padding(12)
|
||||
.onAppear { animate = true }
|
||||
}
|
||||
}
|
||||
|
||||
private struct DashedSunLine: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: rect.minX, y: rect.maxY * 0.75))
|
||||
path.addQuadCurve(
|
||||
to: CGPoint(x: rect.maxX, y: rect.maxY * 0.25),
|
||||
control: CGPoint(x: rect.midX, y: rect.minY + 30)
|
||||
)
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
private struct CameraPicker: UIViewControllerRepresentable {
|
||||
@Binding var image: UIImage?
|
||||
@Binding var isPresented: Bool
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
func makeUIViewController(context: Context) -> UIImagePickerController {
|
||||
let picker = UIImagePickerController()
|
||||
picker.delegate = context.coordinator
|
||||
if UIImagePickerController.isSourceTypeAvailable(.camera) {
|
||||
picker.sourceType = .camera
|
||||
picker.cameraCaptureMode = .photo
|
||||
} else {
|
||||
picker.sourceType = .photoLibrary
|
||||
}
|
||||
return picker
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
|
||||
|
||||
final class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
|
||||
private let parent: CameraPicker
|
||||
|
||||
init(_ parent: CameraPicker) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||||
parent.isPresented = false
|
||||
}
|
||||
|
||||
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
|
||||
if let captured = info[.originalImage] as? UIImage {
|
||||
parent.image = captured
|
||||
}
|
||||
parent.isPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
960
iOS/velocity/velocity/Features/Oracle/OracleView.swift
Normal file
960
iOS/velocity/velocity/Features/Oracle/OracleView.swift
Normal file
@@ -0,0 +1,960 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: – Oracle Canvas Modes
|
||||
enum OracleMode: String, CaseIterable {
|
||||
case pipeline = "Pipeline"
|
||||
case teamPerformance = "Team Performance"
|
||||
case accountTimeline = "Account Timeline"
|
||||
case leadMap = "Lead Map"
|
||||
case calendarTasks = "Calendar & Tasks"
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .pipeline: return "square.grid.3x1.below.line.grid.1x2"
|
||||
case .teamPerformance: return "person.3"
|
||||
case .accountTimeline: return "clock.arrow.circlepath"
|
||||
case .leadMap: return "map"
|
||||
case .calendarTasks: return "calendar"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Pipeline mock data (extended with detail fields)
|
||||
struct OracleLeadCard: Identifiable {
|
||||
let id = UUID()
|
||||
let initials: String
|
||||
let name: String
|
||||
let company: String
|
||||
let value: String
|
||||
let status: LeadStatus
|
||||
let phone: String
|
||||
let interest: String
|
||||
let qualification: String
|
||||
}
|
||||
|
||||
private let pipelineData: [(stage: String, cards: [OracleLeadCard])] = [
|
||||
("New", [
|
||||
OracleLeadCard(initials: "JW", name: "James Wilson", company: "Website", value: "AED 3.5M", status: .new,
|
||||
phone: "+971 52 456 7890", interest: "1BR Investment", qualification: "Potential"),
|
||||
]),
|
||||
("Qualified", [
|
||||
OracleLeadCard(initials: "FH", name: "Fatima Hassan", company: "WhatsApp", value: "AED 12M", status: .qualified,
|
||||
phone: "+971 54 321 0987", interest: "3BR + Maid", qualification: "Whale"),
|
||||
OracleLeadCard(initials: "SC", name: "Sarah Chen", company: "Walk-in", value: "AED 6.5M", status: .engaged,
|
||||
phone: "+971 50 987 6543", interest: "2BR Sea View", qualification: "Potential"),
|
||||
]),
|
||||
("Proposal", [
|
||||
OracleLeadCard(initials: "MA", name: "Mohammed Al-Rashid", company: "WhatsApp", value: "AED 15M+", status: .hot,
|
||||
phone: "+971 55 123 4567", interest: "Penthouse Suite", qualification: "Whale"),
|
||||
]),
|
||||
("Closed", [
|
||||
OracleLeadCard(initials: "DK", name: "David Kumar", company: "Walk-in", value: "AED 20M", status: .closed,
|
||||
phone: "+971 56 789 0123", interest: "Full Floor", qualification: "Whale"),
|
||||
]),
|
||||
]
|
||||
|
||||
struct TeamMemberData: Identifiable {
|
||||
let id = UUID()
|
||||
let initials: String; let name: String; let deals: Int; let revenue: String; let trend: String
|
||||
}
|
||||
private let teamData: [TeamMemberData] = [
|
||||
.init(initials: "RA", name: "Rania Al-Farsi", deals: 42, revenue: "$2.1M", trend: "↑ 18%"),
|
||||
.init(initials: "KM", name: "Khaled Mensah", deals: 31, revenue: "$1.6M", trend: "↑ 12%"),
|
||||
.init(initials: "LT", name: "Lina Torres", deals: 28, revenue: "$1.3M", trend: "→ 2%"),
|
||||
.init(initials: "AH", name: "Ahmed Hassan", deals: 19, revenue: "$0.9M", trend: "↓ 5%"),
|
||||
]
|
||||
|
||||
struct OracleTimelineEvent: Identifiable {
|
||||
let id = UUID()
|
||||
let badge: String; let summary: String; let when: String; let detail: String
|
||||
}
|
||||
private let timelineEvents: [OracleTimelineEvent] = [
|
||||
.init(badge: "MEETING", summary: "VR Amenity Tour – Apex Innovations", when: "2h ago",
|
||||
detail: "CFO and Legal Director attended. Strong reaction to the panoramic sea view suite. Follow-up proposal requested by Tuesday."),
|
||||
.init(badge: "EMAIL", summary: "Proposal deck sent to legal team", when: "Yesterday",
|
||||
detail: "58-page proposal + payment plan schedule sent via DocuSign. Legal team has 5 business days to review."),
|
||||
.init(badge: "CALL", summary: "Budget discussion – CFO confirmed", when: "Mon",
|
||||
detail: "Budget ceiling confirmed at AED 15M+. CFO expressed strong preference for a penthouse unit with private terrace."),
|
||||
.init(badge: "VISIT", summary: "Site walkthrough – Penthouse Suite", when: "Last week",
|
||||
detail: "First in-person visit. 45-minute walkthrough of Penthouse A & B. Visitor dwell time analysis showed 'excited' sentiment for 90% of the visit."),
|
||||
]
|
||||
|
||||
struct RegionPin: Identifiable {
|
||||
let id = UUID()
|
||||
let label: String; let country: String; let count: Int; let temp: String; let topLead: String
|
||||
}
|
||||
private let mapPins: [RegionPin] = [
|
||||
.init(label: "UAE", country: "🇦🇪", count: 8, temp: "hot", topLead: "Mohammed Al-Rashid"),
|
||||
.init(label: "Saudi Arabia", country: "🇸🇦", count: 5, temp: "warm", topLead: "Al-Mansour Group"),
|
||||
.init(label: "UK", country: "🇬🇧", count: 3, temp: "cold", topLead: "Rexford Capital"),
|
||||
.init(label: "USA", country: "🇺🇸", count: 4, temp: "warm", topLead: "Apex Innovations"),
|
||||
.init(label: "India", country: "🇮🇳", count: 6, temp: "hot", topLead: "Starlight Systems"),
|
||||
.init(label: "Germany", country: "🇩🇪", count: 2, temp: "cold", topLead: "TechWave GmbH"),
|
||||
]
|
||||
|
||||
struct CalTask: Identifiable {
|
||||
let id = UUID()
|
||||
let title: String; let subtitle: String; let due: String
|
||||
}
|
||||
private let calTasks: [CalTask] = [
|
||||
.init(title: "Follow up with Mohammed", subtitle: "High-value penthouse lead – 2 unread messages", due: "Today 3 PM"),
|
||||
.init(title: "Send contract to Fatima", subtitle: "3BR unit finalised – payment plan to confirm", due: "Tomorrow 10 AM"),
|
||||
.init(title: "Schedule VR tour – James", subtitle: "Website lead, potential 1BR investor", due: "Thu 2 PM"),
|
||||
]
|
||||
|
||||
// MARK: – OracleView (main)
|
||||
struct OracleView: View {
|
||||
@State private var selectedMode: OracleMode = .pipeline
|
||||
@State private var prompt = "Show me a pipeline view by stage for Q4."
|
||||
@State private var insightText = "Pipeline is healthy. Mohammed Al-Rashid is your highest-value close opportunity — follow up within 24 hours."
|
||||
@State private var isSubmitting = false
|
||||
|
||||
// Sheet states
|
||||
@State private var selectedLead: OracleLeadCard? = nil
|
||||
@State private var selectedMember: TeamMemberData? = nil
|
||||
@State private var selectedRegion: RegionPin? = nil
|
||||
@State private var scheduledTask: CalTask? = nil
|
||||
@State private var showScheduleConfirm = false
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
pageHeader
|
||||
.padding(.horizontal, 24).padding(.top, 24).padding(.bottom, 16)
|
||||
|
||||
insightCard
|
||||
.padding(.horizontal, 24).padding(.bottom, 14)
|
||||
|
||||
ScrollView {
|
||||
canvasView
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 120)
|
||||
}
|
||||
}
|
||||
|
||||
promptBar
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(VelocityTheme.background)
|
||||
|
||||
// Lead detail sheet
|
||||
.sheet(item: $selectedLead) { card in
|
||||
LeadDetailSheet(card: card)
|
||||
}
|
||||
// Team member sheet
|
||||
.sheet(item: $selectedMember) { member in
|
||||
MemberDetailSheet(member: member)
|
||||
}
|
||||
// Region callout sheet
|
||||
.sheet(item: $selectedRegion) { pin in
|
||||
RegionDetailSheet(pin: pin)
|
||||
}
|
||||
// Schedule confirmation alert
|
||||
.alert("Confirm Schedule",
|
||||
isPresented: $showScheduleConfirm,
|
||||
presenting: scheduledTask) { task in
|
||||
Button("Schedule") {
|
||||
// In a real app this would create a calendar event
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: { task in
|
||||
Text("Add \"\(task.title)\" to your calendar for \(task.due)?")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Sub-views
|
||||
private var pageHeader: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Oracle").font(.system(size: 28, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text("AI intelligence pipeline").font(.system(size: 12)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
if isSubmitting {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
|
||||
.scaleEffect(0.8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var insightCard: some View {
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(LinearGradient(colors: [Color(red: 0.58, green: 0.77, blue: 0.99), VelocityTheme.accent],
|
||||
startPoint: .top, endPoint: .bottom))
|
||||
.frame(width: 3)
|
||||
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("AI INSIGHT").font(.system(size: 9, weight: .semibold)).tracking(1.5)
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
Text(insightText).font(.system(size: 13)).foregroundStyle(VelocityTheme.foreground).lineLimit(2)
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: selectedMode.icon).font(.system(size: 11)).foregroundStyle(VelocityTheme.accent)
|
||||
Text(selectedMode.rawValue).font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(Color(red: 0.58, green: 0.77, blue: 0.99))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.vertical, 12)
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(red: 0.09, green: 0.15, blue: 0.33).opacity(0.55))
|
||||
.overlay(RoundedRectangle(cornerRadius: 12).stroke(VelocityTheme.borderAccent, lineWidth: 1))
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var canvasView: some View {
|
||||
switch selectedMode {
|
||||
case .pipeline:
|
||||
PipelineCanvas(onSelectLead: { selectedLead = $0 })
|
||||
case .teamPerformance:
|
||||
TeamPerformanceCanvas(onSelectMember: { selectedMember = $0 })
|
||||
case .accountTimeline:
|
||||
AccountTimelineCanvas()
|
||||
case .leadMap:
|
||||
LeadMapCanvas(onSelectRegion: { selectedRegion = $0 })
|
||||
case .calendarTasks:
|
||||
CalendarCanvas(onSchedule: { task in
|
||||
scheduledTask = task
|
||||
showScheduleConfirm = true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Prompt Bar
|
||||
private var promptBar: some View {
|
||||
VStack(spacing: 0) {
|
||||
TextField("Ask Oracle anything…", text: $prompt)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.tint(VelocityTheme.accent)
|
||||
.onSubmit { submitPrompt() }
|
||||
.padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 8)
|
||||
|
||||
HStack {
|
||||
Menu {
|
||||
ForEach(OracleMode.allCases, id: \.self) { mode in
|
||||
Button {
|
||||
selectedMode = mode
|
||||
prompt = modeSamplePrompt(mode)
|
||||
insightText = oracleInsight(for: mode)
|
||||
} label: {
|
||||
Label(mode.rawValue, systemImage: mode.icon)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: selectedMode.icon).font(.system(size: 10))
|
||||
Text(selectedMode.rawValue).font(.system(size: 11, weight: .medium))
|
||||
Image(systemName: "chevron.down").font(.system(size: 8))
|
||||
}
|
||||
.foregroundStyle(Color(red: 0.58, green: 0.77, blue: 0.99))
|
||||
.padding(.horizontal, 10).padding(.vertical, 6)
|
||||
.background(Capsule().fill(VelocityTheme.accent.opacity(0.14))
|
||||
.overlay(Capsule().stroke(VelocityTheme.accent.opacity(0.3), lineWidth: 1)))
|
||||
}
|
||||
Spacer()
|
||||
Button(action: submitPrompt) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(isSubmitting ? VelocityTheme.mutedFg : VelocityTheme.accent)
|
||||
.shadow(color: VelocityTheme.accent.opacity(0.5), radius: 8)
|
||||
if isSubmitting {
|
||||
ProgressView().progressViewStyle(CircularProgressViewStyle(tint: .white)).scaleEffect(0.6)
|
||||
} else {
|
||||
Image(systemName: "paperplane.fill").font(.system(size: 12)).foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
.frame(width: 34, height: 34)
|
||||
}
|
||||
.disabled(isSubmitting || prompt.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
.padding(.horizontal, 12).padding(.bottom, 12)
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.fill(Color(red: 0.039, green: 0.043, blue: 0.063).opacity(0.95))
|
||||
.overlay(RoundedRectangle(cornerRadius: 18).stroke(Color.white.opacity(0.11), lineWidth: 1))
|
||||
.shadow(color: .black.opacity(0.6), radius: 20, y: -4)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: – Prompt logic
|
||||
private func submitPrompt() {
|
||||
let clean = prompt.trimmingCharacters(in: .whitespaces)
|
||||
guard !clean.isEmpty && !isSubmitting else { return }
|
||||
isSubmitting = true
|
||||
let lower = clean.lowercased()
|
||||
if lower.contains("team") || lower.contains("performance") || lower.contains("sales") {
|
||||
selectedMode = .teamPerformance
|
||||
} else if lower.contains("account") || lower.contains("apex") || lower.contains("timeline") {
|
||||
selectedMode = .accountTimeline
|
||||
} else if lower.contains("map") || lower.contains("geographic") || lower.contains("location") {
|
||||
selectedMode = .leadMap
|
||||
} else if lower.contains("calendar") || lower.contains("schedule") || lower.contains("task") {
|
||||
selectedMode = .calendarTasks
|
||||
} else {
|
||||
selectedMode = .pipeline
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.9) {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
insightText = oracleInsight(for: selectedMode)
|
||||
isSubmitting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func modeSamplePrompt(_ mode: OracleMode) -> String {
|
||||
switch mode {
|
||||
case .pipeline: return "Show me a pipeline view by stage for Q4."
|
||||
case .teamPerformance: return "What's the performance of the sales team this month?"
|
||||
case .accountTimeline: return "Find all contacts at Apex Innovations and their recent activity."
|
||||
case .leadMap: return "Give me a geographic map of all leads."
|
||||
case .calendarTasks: return "Schedule follow-ups with the top 3 high-value leads."
|
||||
}
|
||||
}
|
||||
|
||||
private func oracleInsight(for mode: OracleMode) -> String {
|
||||
switch mode {
|
||||
case .pipeline: return "Pipeline is healthy. Mohammed Al-Rashid is your highest-value close opportunity — follow up within 24 hours."
|
||||
case .teamPerformance: return "Rania Al-Farsi leads the team at $2.1M closed. Overall quota attainment is at 87% — ahead of last month."
|
||||
case .accountTimeline: return "Apex Innovations has 4 active stakeholders. Confusion detected in legal stage — recommend expediting contract review."
|
||||
case .leadMap: return "UAE and India show the hottest lead concentration. 8 high-value prospects in UAE require immediate outreach."
|
||||
case .calendarTasks: return "3 high-priority follow-ups scheduled. Mohammed Al-Rashid has 2 unread messages — respond within 24h."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Pipeline Canvas
|
||||
private struct PipelineCanvas: View {
|
||||
let onSelectLead: (OracleLeadCard) -> Void
|
||||
private let cols = [GridItem(.adaptive(minimum: 200), spacing: 12)]
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(columns: cols, alignment: .leading, spacing: 12) {
|
||||
ForEach(pipelineData, id: \.stage) { col in
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text(col.stage.uppercased())
|
||||
.font(.system(size: 10, weight: .semibold)).tracking(1)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Spacer()
|
||||
Text("\(col.cards.count)")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
.padding(.horizontal, 7).padding(.vertical, 3)
|
||||
.background(Capsule().fill(VelocityTheme.accent.opacity(0.12))
|
||||
.overlay(Capsule().stroke(VelocityTheme.accent.opacity(0.2), lineWidth: 1)))
|
||||
}
|
||||
ForEach(col.cards) { card in
|
||||
TappableLeadCard(card: card, onTap: { onSelectLead(card) })
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderSubtle, lineWidth: 1))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TappableLeadCard: View {
|
||||
let card: OracleLeadCard
|
||||
let onTap: () -> Void
|
||||
@State private var pressed = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8).fill(card.status.color.opacity(0.18)).frame(width: 36, height: 36)
|
||||
Text(card.initials).font(.system(size: 12, weight: .bold)).foregroundStyle(card.status.color)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(card.name).font(.system(size: 12, weight: .medium)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text(card.company).font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(card.value).font(.system(size: 11, weight: .semibold)).foregroundStyle(VelocityTheme.accent)
|
||||
Image(systemName: "chevron.right").font(.system(size: 9)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(pressed ? VelocityTheme.accent.opacity(0.10) : Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(pressed ? VelocityTheme.accent.opacity(0.35) : Color.white.opacity(0.06), lineWidth: 1))
|
||||
)
|
||||
.scaleEffect(pressed ? 0.97 : 1.0)
|
||||
.animation(.easeInOut(duration: 0.12), value: pressed)
|
||||
.onTapGesture {
|
||||
pressed = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { pressed = false }
|
||||
onTap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Lead Detail Sheet
|
||||
private struct LeadDetailSheet: View {
|
||||
let card: OracleLeadCard
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
// Avatar + name
|
||||
HStack(spacing: 16) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 14).fill(card.status.color.opacity(0.20)).frame(width: 60, height: 60)
|
||||
Text(card.initials).font(.system(size: 22, weight: .bold)).foregroundStyle(card.status.color)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(card.name).font(.system(size: 20, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||||
HStack(spacing: 6) {
|
||||
Text(card.status.rawValue)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(card.status.color)
|
||||
.padding(.horizontal, 8).padding(.vertical, 3)
|
||||
.background(Capsule().fill(card.status.color.opacity(0.14)))
|
||||
Text(card.qualification).font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
|
||||
// Details grid
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 14) {
|
||||
DetailField(label: "Deal Value", value: card.value)
|
||||
DetailField(label: "Source", value: card.company)
|
||||
DetailField(label: "Interest", value: card.interest)
|
||||
DetailField(label: "Phone", value: card.phone)
|
||||
}
|
||||
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
|
||||
// Action buttons
|
||||
HStack(spacing: 12) {
|
||||
ActionChip(icon: "phone.fill", label: "Call", color: VelocityTheme.success)
|
||||
ActionChip(icon: "message.fill", label: "Message", color: VelocityTheme.accent)
|
||||
ActionChip(icon: "calendar.badge.plus", label: "Schedule", color: Color(red: 0.60, green: 0.57, blue: 0.99))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(24)
|
||||
.background(VelocityTheme.background.ignoresSafeArea())
|
||||
.navigationTitle("Lead Details")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DetailField: View {
|
||||
let label: String; let value: String
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(label.uppercased()).font(.system(size: 9, weight: .semibold)).tracking(1)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(value).font(.system(size: 13, weight: .medium)).foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.white.opacity(0.07), lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
private struct ActionChip: View {
|
||||
let icon: String; let label: String; let color: Color
|
||||
@State private var pressed = false
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: icon).font(.system(size: 12))
|
||||
Text(label).font(.system(size: 12, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(color)
|
||||
.padding(.horizontal, 16).padding(.vertical, 10)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(color.opacity(pressed ? 0.22 : 0.13))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(color.opacity(0.30), lineWidth: 1)))
|
||||
.scaleEffect(pressed ? 0.96 : 1.0)
|
||||
.animation(.easeInOut(duration: 0.12), value: pressed)
|
||||
.onTapGesture { pressed = true; DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { pressed = false } }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Team Performance Canvas
|
||||
private struct TeamPerformanceCanvas: View {
|
||||
let onSelectMember: (TeamMemberData) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
quotaPanel
|
||||
teamListPanel
|
||||
}
|
||||
}
|
||||
|
||||
private var quotaPanel: some View {
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
Circle().stroke(Color.white.opacity(0.06), lineWidth: 10).frame(width: 110, height: 110)
|
||||
Circle()
|
||||
.trim(from: 0, to: 0.87)
|
||||
.stroke(AngularGradient(colors: [VelocityTheme.accent, Color(red: 0.13, green: 0.83, blue: 0.93)],
|
||||
center: .center),
|
||||
style: StrokeStyle(lineWidth: 10, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.frame(width: 110, height: 110)
|
||||
VStack(spacing: 2) {
|
||||
Text("87%").font(.system(size: 26, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text("QUOTA").font(.system(size: 8, weight: .semibold)).tracking(1.2).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Quota Attainment").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Monthly target exceeded").font(.system(size: 11)).foregroundStyle(VelocityTheme.success)
|
||||
Text("Q4 FY2025–26").font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(20)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
|
||||
private var teamListPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("TEAM PERFORMANCE").font(.system(size: 10, weight: .semibold)).tracking(1.2)
|
||||
.foregroundStyle(VelocityTheme.mutedFg).padding(.bottom, 8)
|
||||
ForEach(teamData) { member in
|
||||
TappableTeamRow(member: member, onTap: { onSelectMember(member) })
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
private struct TappableTeamRow: View {
|
||||
let member: TeamMemberData
|
||||
let onTap: () -> Void
|
||||
@State private var pressed = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8).fill(VelocityTheme.accent.opacity(0.18)).frame(width: 36, height: 36)
|
||||
Text(member.initials).font(.system(size: 12, weight: .bold)).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(member.name).font(.system(size: 13, weight: .medium)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text("\(member.deals) deals closed").font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(member.revenue).font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.accent)
|
||||
Text(member.trend)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(member.trend.hasPrefix("↑") ? VelocityTheme.success :
|
||||
member.trend.hasPrefix("↓") ? VelocityTheme.danger : VelocityTheme.mutedFg)
|
||||
}
|
||||
Image(systemName: "chevron.right").font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 10)
|
||||
.fill(pressed ? VelocityTheme.accent.opacity(0.08) : Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(pressed ? VelocityTheme.accent.opacity(0.25) : Color.white.opacity(0.06), lineWidth: 1)))
|
||||
.scaleEffect(pressed ? 0.98 : 1.0)
|
||||
.animation(.easeInOut(duration: 0.12), value: pressed)
|
||||
.onTapGesture {
|
||||
pressed = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { pressed = false }
|
||||
onTap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Team Member Detail Sheet
|
||||
private struct MemberDetailSheet: View {
|
||||
let member: TeamMemberData
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
HStack(spacing: 16) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 14).fill(VelocityTheme.accent.opacity(0.18)).frame(width: 60, height: 60)
|
||||
Text(member.initials).font(.system(size: 22, weight: .bold)).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(member.name).font(.system(size: 20, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Sales Executive").font(.system(size: 12)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 14) {
|
||||
DetailField(label: "Revenue Closed", value: member.revenue)
|
||||
DetailField(label: "Deals Closed", value: "\(member.deals)")
|
||||
DetailField(label: "Trend", value: member.trend)
|
||||
DetailField(label: "Period", value: "Q4 FY2025–26")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(24)
|
||||
.background(VelocityTheme.background.ignoresSafeArea())
|
||||
.navigationTitle("Team Member")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") { dismiss() }.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Account Timeline Canvas
|
||||
private struct AccountTimelineCanvas: View {
|
||||
@State private var expandedId: UUID? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
// Account overview
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("ACCOUNT OVERVIEW").font(.system(size: 9, weight: .semibold)).tracking(1.5).foregroundStyle(VelocityTheme.accent)
|
||||
Text("Apex Innovations").font(.system(size: 24, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||||
HStack(spacing: 14) {
|
||||
InfoMini(label: "Deal Value", value: "AED 15M+")
|
||||
InfoMini(label: "Primary Contact", value: "CEO – James T.")
|
||||
InfoMini(label: "Industry", value: "Technology")
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
|
||||
// Expandable timeline
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Activity Timeline").font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground).padding(.bottom, 16)
|
||||
ForEach(Array(timelineEvents.enumerated()), id: \.offset) { i, event in
|
||||
TimelineEventRow(event: event, isLast: i == timelineEvents.count - 1,
|
||||
isExpanded: expandedId == event.id) {
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
expandedId = expandedId == event.id ? nil : event.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TimelineEventRow: View {
|
||||
let event: OracleTimelineEvent
|
||||
let isLast: Bool
|
||||
let isExpanded: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
VStack(spacing: 0) {
|
||||
Circle().fill(VelocityTheme.accent).frame(width: 10, height: 10)
|
||||
.overlay(Circle().stroke(VelocityTheme.background, lineWidth: 2))
|
||||
if !isLast {
|
||||
Rectangle()
|
||||
.fill(LinearGradient(colors: [VelocityTheme.accent.opacity(0.5), .clear],
|
||||
startPoint: .top, endPoint: .bottom))
|
||||
.frame(width: 2)
|
||||
.frame(height: isExpanded ? 100 : 50)
|
||||
.animation(.easeInOut(duration: 0.25), value: isExpanded)
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(event.badge).font(.system(size: 9, weight: .bold)).tracking(1.2)
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
.padding(.horizontal, 7).padding(.vertical, 3)
|
||||
.background(RoundedRectangle(cornerRadius: 4).fill(VelocityTheme.accent.opacity(0.15))
|
||||
.overlay(RoundedRectangle(cornerRadius: 4).stroke(VelocityTheme.accent.opacity(0.25), lineWidth: 1)))
|
||||
Spacer()
|
||||
Text(event.when).font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
||||
.font(.system(size: 9)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Text(event.summary).font(.system(size: 13, weight: .medium)).foregroundStyle(VelocityTheme.foreground)
|
||||
if isExpanded {
|
||||
Text(event.detail).font(.system(size: 12)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
.padding(.top, 4)
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 10)
|
||||
.fill(isExpanded ? VelocityTheme.accent.opacity(0.06) : Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(isExpanded ? VelocityTheme.accent.opacity(0.2) : Color.white.opacity(0.06), lineWidth: 1)))
|
||||
.onTapGesture { onTap() }
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct InfoMini: View {
|
||||
let label: String; let value: String
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label).font(.system(size: 9)).tracking(0.8).foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(value).font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
.padding(10)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.white.opacity(0.06), lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Lead Map Canvas
|
||||
private struct LeadMapCanvas: View {
|
||||
let onSelectRegion: (RegionPin) -> Void
|
||||
private let cols = [GridItem(.adaptive(minimum: 140), spacing: 10)]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(spacing: 16) {
|
||||
LegendDot(color: VelocityTheme.danger, label: "Hot Lead")
|
||||
LegendDot(color: Color(red: 0.13, green: 0.83, blue: 0.93), label: "Warm Lead")
|
||||
LegendDot(color: VelocityTheme.mutedFg, label: "Cold Lead")
|
||||
Spacer()
|
||||
}
|
||||
LazyVGrid(columns: cols, spacing: 10) {
|
||||
ForEach(mapPins) { pin in
|
||||
TappableRegionPin(pin: pin, onTap: { onSelectRegion(pin) })
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
private struct TappableRegionPin: View {
|
||||
let pin: RegionPin
|
||||
let onTap: () -> Void
|
||||
@State private var pressed = false
|
||||
|
||||
private var pinColor: Color {
|
||||
pin.temp == "hot" ? VelocityTheme.danger :
|
||||
pin.temp == "warm" ? Color(red: 0.13, green: 0.83, blue: 0.93) : VelocityTheme.mutedFg
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Text(pin.country).font(.system(size: 24))
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(pin.label).font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
HStack(spacing: 4) {
|
||||
Circle().fill(pinColor).frame(width: 6, height: 6).shadow(color: pinColor.opacity(0.8), radius: 3)
|
||||
Text("\(pin.count) leads").font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.right.circle")
|
||||
.font(.system(size: 13)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 10)
|
||||
.fill(pressed ? pinColor.opacity(0.12) : Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(pinColor.opacity(pressed ? 0.5 : 0.25), lineWidth: 1)))
|
||||
.scaleEffect(pressed ? 0.97 : 1.0)
|
||||
.animation(.easeInOut(duration: 0.12), value: pressed)
|
||||
.onTapGesture {
|
||||
pressed = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { pressed = false }
|
||||
onTap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LegendDot: View {
|
||||
let color: Color; let label: String
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Circle().fill(color).frame(width: 8, height: 8).shadow(color: color.opacity(0.8), radius: 3)
|
||||
Text(label).font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Region Detail Sheet
|
||||
private struct RegionDetailSheet: View {
|
||||
let pin: RegionPin
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
private var pinColor: Color {
|
||||
pin.temp == "hot" ? VelocityTheme.danger :
|
||||
pin.temp == "warm" ? Color(red: 0.13, green: 0.83, blue: 0.93) : VelocityTheme.mutedFg
|
||||
}
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
HStack(spacing: 16) {
|
||||
Text(pin.country).font(.system(size: 52))
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(pin.label).font(.system(size: 22, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||||
HStack(spacing: 6) {
|
||||
Circle().fill(pinColor).frame(width: 7, height: 7)
|
||||
Text(pin.temp.capitalized + " Market")
|
||||
.font(.system(size: 12, weight: .medium)).foregroundStyle(pinColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 14) {
|
||||
DetailField(label: "Active Leads", value: "\(pin.count)")
|
||||
DetailField(label: "Top Lead", value: pin.topLead)
|
||||
DetailField(label: "Temperature", value: pin.temp.capitalized)
|
||||
DetailField(label: "Priority", value: pin.temp == "hot" ? "High 🔴" : pin.temp == "warm" ? "Medium 🟡" : "Low ⚪")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(24)
|
||||
.background(VelocityTheme.background.ignoresSafeArea())
|
||||
.navigationTitle("Region Details")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") { dismiss() }.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Calendar Canvas
|
||||
private struct CalendarCanvas: View {
|
||||
let onSchedule: (CalTask) -> Void
|
||||
let days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
weekPanel
|
||||
tasksPanel
|
||||
}
|
||||
}
|
||||
|
||||
private var weekPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Weekly Schedule").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
HStack(spacing: 6) {
|
||||
ForEach(days, id: \.self) { day in
|
||||
Text(day).font(.system(size: 10, weight: .medium)).tracking(0.5)
|
||||
.foregroundStyle(VelocityTheme.mutedFg).frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
ForEach(Array(days.enumerated()), id: \.offset) { i, _ in
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(i == 2 ? VelocityTheme.accent.opacity(0.15) : Color.white.opacity(0.03))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(i == 2 ? VelocityTheme.accent.opacity(0.3) : Color.white.opacity(0.05), lineWidth: 1))
|
||||
.frame(height: 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
|
||||
private var tasksPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 5) {
|
||||
Circle().fill(Color(red: 0.13, green: 0.83, blue: 0.93)).frame(width: 6, height: 6)
|
||||
Text("Tasks & Actions").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
.padding(.bottom, 4)
|
||||
ForEach(calTasks) { task in
|
||||
CalTaskRow(task: task, onSchedule: { onSchedule(task) })
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
private struct CalTaskRow: View {
|
||||
let task: CalTask
|
||||
let onSchedule: () -> Void
|
||||
@State private var scheduled = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(task.title).font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
Text(scheduled ? "Scheduled ✓" : "Action")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(scheduled ? VelocityTheme.success : VelocityTheme.mutedFg)
|
||||
.padding(.horizontal, 6).padding(.vertical, 3)
|
||||
.background(RoundedRectangle(cornerRadius: 4)
|
||||
.fill(scheduled ? VelocityTheme.success.opacity(0.12) : Color.white.opacity(0.06)))
|
||||
}
|
||||
Text(task.subtitle).font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg).lineLimit(2)
|
||||
HStack {
|
||||
Image(systemName: "clock").font(.system(size: 10)).foregroundStyle(VelocityTheme.accent)
|
||||
Text(task.due).font(.system(size: 11)).foregroundStyle(VelocityTheme.accent)
|
||||
Spacer()
|
||||
Button {
|
||||
onSchedule()
|
||||
withAnimation(.easeInOut(duration: 0.3)) { scheduled = true }
|
||||
} label: {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: scheduled ? "checkmark" : "calendar.badge.plus")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
Text(scheduled ? "Scheduled" : "Schedule")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 12).padding(.vertical, 5)
|
||||
.background(RoundedRectangle(cornerRadius: 7)
|
||||
.fill(scheduled ? VelocityTheme.success : VelocityTheme.accent)
|
||||
.shadow(color: (scheduled ? VelocityTheme.success : VelocityTheme.accent).opacity(0.4), radius: 6))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 10)
|
||||
.fill(scheduled ? VelocityTheme.success.opacity(0.05) : Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(scheduled ? VelocityTheme.success.opacity(0.2) : Color.white.opacity(0.06), lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
413
iOS/velocity/velocity/Features/Sentinel/SentinelView.swift
Normal file
413
iOS/velocity/velocity/Features/Sentinel/SentinelView.swift
Normal file
@@ -0,0 +1,413 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SentinelView: View {
|
||||
private var store: AppStore { AppStore.shared }
|
||||
private let indigo = Color(red: 0.60, green: 0.57, blue: 0.99)
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
pageHeader
|
||||
kpiGrid
|
||||
analyticsRow
|
||||
bottomRow
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.background(VelocityTheme.background)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
|
||||
// MARK: – Sub-views extracted so the type-checker can cope
|
||||
private var pageHeader: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Sentinel")
|
||||
.font(.system(size: 28, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text("FaceID · visitor analytics · real-time alerts")
|
||||
.font(.system(size: 12)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
|
||||
private var kpiGrid: some View {
|
||||
let cols = [GridItem(.adaptive(minimum: 180), spacing: 12)]
|
||||
return LazyVGrid(columns: cols, spacing: 12) {
|
||||
SentinelKPI(icon: "person.2.fill", iconColor: VelocityTheme.accent,
|
||||
label: "Active Visitors", value: "\(store.visitors.count)",
|
||||
sub: "Currently tracked", badge: "LIVE")
|
||||
SentinelKPI(icon: "waveform.path.ecg", iconColor: VelocityTheme.success,
|
||||
label: "Avg Sentiment", value: "\(avgSentiment)%",
|
||||
sub: "Overall mood")
|
||||
SentinelKPI(icon: "eye.fill", iconColor: indigo,
|
||||
label: "Detection Accuracy", value: "\(avgConfidence)%",
|
||||
sub: "Avg confidence")
|
||||
SentinelKPI(icon: "faceid", iconColor: VelocityTheme.warning,
|
||||
label: "Tracked Today", value: "47",
|
||||
sub: "Unique faces")
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.4), value: store.visitors.count)
|
||||
}
|
||||
|
||||
private var analyticsRow: some View {
|
||||
let cols = [GridItem(.flexible(), spacing: 14), GridItem(.flexible(minimum: 260), spacing: 14)]
|
||||
return LazyVGrid(columns: cols, alignment: .leading, spacing: 14) {
|
||||
ZoneAnalyticsPanel()
|
||||
ClientInsightsPanel()
|
||||
}
|
||||
}
|
||||
|
||||
private var bottomRow: some View {
|
||||
let cols = [GridItem(.flexible(), spacing: 14),
|
||||
GridItem(.flexible(), spacing: 14),
|
||||
GridItem(.flexible(), spacing: 14)]
|
||||
return LazyVGrid(columns: cols, alignment: .leading, spacing: 14) {
|
||||
SentimentDistributionPanel(visitors: store.visitors)
|
||||
DwellTimePanel()
|
||||
AlertPanel(isActive: store.isAlertActive, message: store.alertMessage)
|
||||
}
|
||||
}
|
||||
|
||||
private var avgSentiment: Int {
|
||||
guard !store.visitors.isEmpty else { return 0 }
|
||||
let total = store.visitors.reduce(0) { $0 + $1.sentiment.score }
|
||||
return total / store.visitors.count
|
||||
}
|
||||
|
||||
private var avgConfidence: Int {
|
||||
guard !store.visitors.isEmpty else { return 0 }
|
||||
let total = store.visitors.reduce(0.0) { $0 + $1.confidence }
|
||||
return Int((total / Double(store.visitors.count)) * 100)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – KPI Card
|
||||
private struct SentinelKPI: View {
|
||||
let icon: String; let iconColor: Color
|
||||
let label: String; let value: String; let sub: String
|
||||
var badge: String? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8).fill(iconColor.opacity(0.14)).frame(width: 34, height: 34)
|
||||
Image(systemName: icon).font(.system(size: 14)).foregroundStyle(iconColor)
|
||||
}
|
||||
Spacer()
|
||||
if let badge {
|
||||
HStack(spacing: 4) {
|
||||
Circle().fill(VelocityTheme.success).frame(width: 5, height: 5)
|
||||
Text(badge).font(.system(size: 9, weight: .semibold)).foregroundStyle(VelocityTheme.success)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(label.uppercased()).font(.system(size: 10, weight: .medium)).tracking(1.0)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(value).font(.system(size: 30, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
.contentTransition(.numericText())
|
||||
Text(sub).font(.system(size: 11)).foregroundStyle(VelocityTheme.subtleFg)
|
||||
}
|
||||
.padding(18)
|
||||
.frame(maxWidth: .infinity, minHeight: 140, alignment: .topLeading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(iconColor.opacity(0.18), lineWidth: 1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Zone Analytics
|
||||
private struct ZoneAnalyticsPanel: View {
|
||||
private let zones: [(id: String, name: String, count: Int, sentiment: Int)] = [
|
||||
("A", "Main Showroom", 5, 72),
|
||||
("B", "Penthouse Gallery",3, 85),
|
||||
("C", "Amenity Deck VR", 2, 68),
|
||||
("D", "Reception", 2, 90),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "mappin.and.ellipse").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent)
|
||||
Text("Zone Analytics").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
ForEach(zones, id: \.id) { zone in
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 6).fill(VelocityTheme.accent.opacity(0.14)).frame(width: 28, height: 28)
|
||||
Text(zone.id).font(.system(size: 11, weight: .bold)).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(zone.name).font(.system(size: 12, weight: .medium)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text("\(zone.count) visitors").font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 4) {
|
||||
let c: Color = zone.sentiment >= 80 ? VelocityTheme.success :
|
||||
zone.sentiment >= 60 ? VelocityTheme.accent : VelocityTheme.warning
|
||||
Circle().fill(c).frame(width: 7, height: 7)
|
||||
Text("\(zone.sentiment)%").font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.white.opacity(0.06), lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Client Insights
|
||||
private struct ClientInsightsPanel: View {
|
||||
private struct Insight {
|
||||
let name: String; let stage: String; let sentiment: String
|
||||
let score: Int; let insight: String; let color: Color
|
||||
|
||||
var icon: String {
|
||||
score >= 80 ? "arrow.up.right" : score >= 50 ? "minus" : "exclamationmark.triangle"
|
||||
}
|
||||
var scoreColor: Color {
|
||||
score >= 80 ? VelocityTheme.success : score >= 50 ? VelocityTheme.warning : VelocityTheme.danger
|
||||
}
|
||||
}
|
||||
|
||||
private let insights: [Insight] = [
|
||||
.init(name: "Quantum Dynamics", stage: "Proposal Review", sentiment: "Positive", score: 92,
|
||||
insight: "Key decision maker showed high engagement. Emphasise custom penthouse integration.",
|
||||
color: VelocityTheme.success),
|
||||
.init(name: "Nebula Ventures", stage: "Discovery", sentiment: "Neutral", score: 45,
|
||||
insight: "Initial interest detected but hesitation around pricing model tier.",
|
||||
color: VelocityTheme.warning),
|
||||
.init(name: "Apex Industries", stage: "Negotiation", sentiment: "Mixed", score: 68,
|
||||
insight: "Confusion markers during contract terms review. Legal team is the blocker.",
|
||||
color: VelocityTheme.danger),
|
||||
.init(name: "Starlight Systems", stage: "Initial Contact", sentiment: "Positive", score: 78,
|
||||
insight: "Strong ESG engagement. Align pitch with sustainability goals to accelerate close.",
|
||||
color: VelocityTheme.accent),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
insightHeader
|
||||
insightGrid
|
||||
}
|
||||
.padding(16)
|
||||
.background(RoundedRectangle(cornerRadius: 14)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
|
||||
private var insightHeader: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "sparkles").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent)
|
||||
Text("AI Strategic Insights")
|
||||
.font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
Text("LIVE ANALYSIS").font(.system(size: 8, weight: .bold)).tracking(1)
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
.padding(.horizontal, 6).padding(.vertical, 3)
|
||||
.background(RoundedRectangle(cornerRadius: 4).fill(VelocityTheme.accent.opacity(0.12))
|
||||
.overlay(RoundedRectangle(cornerRadius: 4)
|
||||
.stroke(VelocityTheme.accent.opacity(0.2), lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
private var insightGrid: some View {
|
||||
let cols = [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)]
|
||||
return LazyVGrid(columns: cols, alignment: .leading, spacing: 10) {
|
||||
ForEach(insights, id: \.name) { item in
|
||||
InsightCard(
|
||||
name: item.name, stage: item.stage, sentiment: item.sentiment,
|
||||
score: item.score, insight: item.insight, color: item.color,
|
||||
icon: item.icon, scoreColor: item.scoreColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct InsightCard: View {
|
||||
struct Item {
|
||||
let name: String; let stage: String; let sentiment: String
|
||||
let score: Int; let insight: String; let color: Color
|
||||
var icon: String { score >= 80 ? "arrow.up.right" : score >= 50 ? "minus" : "exclamationmark.triangle" }
|
||||
var scoreColor: Color { score >= 80 ? VelocityTheme.success : score >= 50 ? VelocityTheme.warning : VelocityTheme.danger }
|
||||
}
|
||||
|
||||
// Accept ClientInsightsPanel.Insight via protocol duck-typing workaround:
|
||||
let name: String; let stage: String; let sentiment: String
|
||||
let score: Int; let insight: String; let color: Color
|
||||
let icon: String; let scoreColor: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 6).fill(color.opacity(0.18)).frame(width: 28, height: 28)
|
||||
Image(systemName: icon).font(.system(size: 11)).foregroundStyle(color)
|
||||
}
|
||||
Spacer()
|
||||
Text("\(score)").font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(scoreColor)
|
||||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||
.background(RoundedRectangle(cornerRadius: 4).fill(scoreColor.opacity(0.15)))
|
||||
}
|
||||
Text(name).font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground).lineLimit(1)
|
||||
Text(insight).font(.system(size: 10))
|
||||
.foregroundStyle(VelocityTheme.mutedFg).lineLimit(3)
|
||||
HStack {
|
||||
Text(stage).font(.system(size: 9)).foregroundStyle(VelocityTheme.subtleFg)
|
||||
Spacer()
|
||||
Text(sentiment).font(.system(size: 9, weight: .semibold)).foregroundStyle(color)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(color.opacity(0.15), lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: – Sentiment Distribution
|
||||
private struct SentimentDistributionPanel: View {
|
||||
let visitors: [Visitor]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "chart.bar").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent)
|
||||
Text("Sentiment").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
ForEach(SentimentType.allCases, id: \.self) { type in
|
||||
let count = visitors.filter { $0.sentiment == type }.count
|
||||
let fraction = visitors.isEmpty ? 0.0 : Double(count) / Double(visitors.count)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(type.emoji).font(.system(size: 14))
|
||||
Text(type.rawValue.capitalized).font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
Spacer()
|
||||
Text("\(count)").font(.system(size: 11, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.06)).frame(height: 5)
|
||||
RoundedRectangle(cornerRadius: 3).fill(type.color)
|
||||
.frame(width: geo.size.width * fraction, height: 5)
|
||||
.animation(.easeOut(duration: 0.6), value: fraction)
|
||||
}
|
||||
}
|
||||
.frame(height: 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Dwell Time Panel
|
||||
private struct DwellTimePanel: View {
|
||||
private let data: [(range: String, count: Int, trend: String)] = [
|
||||
("< 5 min", 3, "down"),
|
||||
("5–15 min", 5, "up"),
|
||||
("15–30 min", 8, "up"),
|
||||
("> 30 min", 4, "stable"),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "timer").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent)
|
||||
Text("Dwell Time").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
let cols = [GridItem(.flexible(), spacing: 8), GridItem(.flexible(), spacing: 8)]
|
||||
LazyVGrid(columns: cols, spacing: 8) {
|
||||
ForEach(data, id: \.range) { item in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(item.range).font(.system(size: 9)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
Spacer()
|
||||
Image(systemName: item.trend == "up" ? "arrow.up.right" :
|
||||
item.trend == "down" ? "arrow.down.right" : "minus")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(item.trend == "up" ? VelocityTheme.success :
|
||||
item.trend == "down" ? VelocityTheme.danger : VelocityTheme.mutedFg)
|
||||
}
|
||||
Text("\(item.count)").font(.system(size: 22, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text("visitors").font(.system(size: 9)).foregroundStyle(VelocityTheme.subtleFg)
|
||||
}
|
||||
.padding(10)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.white.opacity(0.06), lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Alert Panel
|
||||
private struct AlertPanel: View {
|
||||
let isActive: Bool
|
||||
let message: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "bell.badge").font(.system(size: 12)).foregroundStyle(VelocityTheme.warning)
|
||||
Text("Alerts").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
Text(isActive ? "Active" : "Clear")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(isActive ? VelocityTheme.warning : VelocityTheme.success)
|
||||
.padding(.horizontal, 7).padding(.vertical, 3)
|
||||
.background(Capsule().fill((isActive ? VelocityTheme.warning : VelocityTheme.success).opacity(0.15))
|
||||
.overlay(Capsule().stroke((isActive ? VelocityTheme.warning : VelocityTheme.success).opacity(0.25), lineWidth: 1)))
|
||||
}
|
||||
|
||||
if isActive {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 14)).foregroundStyle(VelocityTheme.warning)
|
||||
Text("Sentiment Alert").font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.warning)
|
||||
}
|
||||
Text(message).font(.system(size: 12)).foregroundStyle(VelocityTheme.foreground).lineLimit(3)
|
||||
Text("Just now").font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(VelocityTheme.warning.opacity(0.08))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(VelocityTheme.warning.opacity(0.3), lineWidth: 1)))
|
||||
.transition(.asymmetric(insertion: .scale(scale: 0.95).combined(with: .opacity),
|
||||
removal: .scale(scale: 0.95).combined(with: .opacity)))
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "checkmark.shield.fill")
|
||||
.font(.system(size: 14)).foregroundStyle(VelocityTheme.success)
|
||||
Text("All Clear").font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.success)
|
||||
}
|
||||
Text("No alerts at this time").font(.system(size: 12)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text("System nominal").font(.system(size: 10)).foregroundStyle(VelocityTheme.subtleFg)
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(VelocityTheme.success.opacity(0.08))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(VelocityTheme.success.opacity(0.3), lineWidth: 1)))
|
||||
.transition(.asymmetric(insertion: .scale(scale: 0.95).combined(with: .opacity),
|
||||
removal: .scale(scale: 0.95).combined(with: .opacity)))
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.animation(.easeInOut(duration: 0.3), value: isActive)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
141
iOS/velocity/velocity/Features/Settings/SettingsView.swift
Normal file
141
iOS/velocity/velocity/Features/Settings/SettingsView.swift
Normal file
@@ -0,0 +1,141 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
// Page header
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Settings")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Configuration")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
|
||||
// System (live) section
|
||||
SettingsSection(title: "System") {
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.fill(VelocityTheme.success.opacity(0.12)).frame(width: 30, height: 30)
|
||||
Image(systemName: "bolt.fill")
|
||||
.font(.system(size: 13, weight: .medium)).foregroundStyle(VelocityTheme.success)
|
||||
}
|
||||
Text("Connection Status").font(.system(size: 14)).foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
HStack(spacing: 5) {
|
||||
Circle().fill(VelocityTheme.success).frame(width: 6, height: 6)
|
||||
Text("Online").font(.system(size: 12, weight: .medium)).foregroundStyle(VelocityTheme.success)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16).padding(.vertical, 12)
|
||||
}
|
||||
|
||||
// Backend section
|
||||
SettingsSection(title: "Backend") {
|
||||
SettingsRow(label: "ComfyUI Endpoint",
|
||||
value: "http://192.168.x.x:8000",
|
||||
icon: "server.rack",
|
||||
accentColor: VelocityTheme.accent)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(label: "Dream Weaver Path",
|
||||
value: "/dream-weaver",
|
||||
icon: "arrow.triangle.branch",
|
||||
accentColor: VelocityTheme.accent)
|
||||
}
|
||||
|
||||
// Display section
|
||||
SettingsSection(title: "Display") {
|
||||
SettingsRow(label: "Orientation",
|
||||
value: "Landscape Only",
|
||||
icon: "rectangle.landscape.rotate",
|
||||
accentColor: VelocityTheme.mutedFg)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(label: "Theme",
|
||||
value: "Dark",
|
||||
icon: "moon.fill",
|
||||
accentColor: Color(red: 0.60, green: 0.57, blue: 0.99))
|
||||
}
|
||||
|
||||
// App info section
|
||||
SettingsSection(title: "About") {
|
||||
SettingsRow(label: "Version",
|
||||
value: "1.1.0",
|
||||
icon: "info.circle",
|
||||
accentColor: VelocityTheme.mutedFg)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(label: "Build",
|
||||
value: "SwiftUI · iOS 17+",
|
||||
icon: "hammer",
|
||||
accentColor: VelocityTheme.mutedFg)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(24)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(VelocityTheme.background)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsSection<Content: View>: View {
|
||||
let title: String
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(title.uppercased())
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.tracking(1.2)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.padding(.bottom, 8)
|
||||
.padding(.horizontal, 4)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
content
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsRow: View {
|
||||
let label: String
|
||||
let value: String
|
||||
let icon: String
|
||||
let accentColor: Color
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.fill(accentColor.opacity(0.12))
|
||||
.frame(width: 30, height: 30)
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(accentColor)
|
||||
}
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(value)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user