diff --git a/Architecture_Diagram.md b/Architecture_Diagram.md
index 85521aa..329d53a 100644
--- a/Architecture_Diagram.md
+++ b/Architecture_Diagram.md
@@ -1,134 +1,49 @@
-# Forge Architecture & Data Flow Diagram
+# Forge Architecture
-## ๐๏ธ **System Architecture Overview**
-
-```mermaid
-graph TD
- subgraph ForgeSystem [FORGE SYSTEM]
- subgraph Clients [Clients #40;Read-Only#41;]
- ClientA[CLIENT A]
- ClientB[CLIENT B]
- ClientN[CLIENT N]
-
- subgraph OptimisticCache [Optimistic Cache]
- ActorObj[Actor Object
- loadout
- position
- stats]
- end
-
- ClientA --- OptimisticCache
- ClientB --- OptimisticCache
- ClientN --- OptimisticCache
- end
-
- subgraph Server [ArmA 3 SERVER #40;Hot Cache#41;]
- Registry["GVAR(Registry)
In-Memory HashMap
UID -> {loadout, position, stats...}"]
- SessionMgmt[Session Management
- Token Generation
- UID Resolution
- Player State]
- end
-
- subgraph Rust [EXTENSION #40;Cold Storage#41;]
- ConnPool["Connection Pool
(bb8-redis)
2-10 connections"]
- RedisOps[Redis Operations
- actor_get/set/update
- Async I/O]
- end
-
- subgraph Redis [DATABASE #40;Saved to Disc#41;]
- ActorDataStore[Actor Data Store
actor:UID -> JSON]
- Modules[Additional Modules
garage, locker, bank, org]
- end
-
- Clients -->|Event Driven
#40;CBA A3 Events#41;| Server
- Server -->|Extension Calls
#40;Rust FFI#41;| Rust
- Rust -->|Redis Protocol
#40;bb8-redis#41;| Redis
- end
-```
-
-## ๐ **Data Flow Sequence**
-
-### **1. Player Connection & Initial Data Load**
-
-```mermaid
-sequenceDiagram
- participant Client
- participant Server as Server (Hot Cache)
- participant Extension as Extension (Cold Storage)
- participant Redis as Redis (Database)
-
- Note over Client, Redis: 1. Player Connection & Initial Data Load
-
- Client->>Server: 1. Connect
- Client->>Server: 2. Request Actor Data
- Server->>Server: 3. Check Cache (Cache Miss)
- Server->>Extension: 4. Extension Call
- Extension->>Redis: 5. Redis Query
- Redis-->>Extension: 6. JSON Data
- Extension-->>Server: 7. Actor Data
- Server->>Server: 8. Store in Hot Cache
- Server-->>Client: 9. Secure Response
- Client->>Client: 10. Update Local Cache
-```
-
-### **2. Subsequent Data Access (Cache Hit)**
-
-```mermaid
-sequenceDiagram
- participant Client
- participant Server as Server (Hot Cache)
- participant Extension as Extension (Cold Storage)
- participant Redis as Redis (Database)
-
- Note over Client, Redis: 2. Subsequent Data Access (Cache Hit)
-
- Client->>Server: 1. Request Actor Data
- Server->>Server: 2. Check Cache (Cache Hit!)
- Server-->>Client: 3. Instant Response
- Client->>Client: 4. Update Local Cache
-```
-
-### **3. Data Update (Write-Through)**
-
-```mermaid
-sequenceDiagram
- participant Client
- participant Server as Server (Hot Cache)
- participant Extension as Extension (Cold Storage)
- participant Redis as Redis (Database)
-
- Note over Client, Redis: 3. Data Update (Write-Through)
-
- Client->>Server: 1. Action (Move, etc)
- Server->>Server: 2. Validate & Update Cache
- Server->>Extension: 3. Persist to Database
- Extension->>Redis: 4. Redis Update
- Redis-->>Extension: 5. Confirmation
- Extension-->>Server: 6. Success
- Server-->>Client: 7. Sync to All Clients
-```
-
-## ๐ **Performance Characteristics**
-
-### **Access Times**
-
-- **Hot Cache (Server)**: `< 1ms` (HashMap lookup)
-- **Cold Storage (Redis)**: `1-5ms` (Network + Redis)
-- **Client Cache**: `< 0.1ms` (Local object access)
-
-### **Cache Hit Ratios**
-
-- **Hot Cache**: `~95%` (Active players)
-- **Cold Storage**: `~5%` (New connections, cache misses)
-
-### **Memory Usage**
-
-- **Server Registry**: `~1KB per active player`
-- **Client Cache**: `~500B per player object`
-- **Redis**: `~2KB per player (persistent)`
-
-## ๐ **Security & Session Management**
+## Runtime Flow
```mermaid
flowchart TD
- subgraph SessionMgmt [SERVER-SIDE #40;Session MGT#41;]
- Conn[Player Connection] --> Token[Session Token Generation
#40;Generated on server#41;]
- Token --> UID[UID Resolution
#40;Steam UID mapping#41;]
- UID --> State[Player State Tracking
#40;Tracked in Registry#41;]
- State --> Access[Data Access Authorized
#40;Authorized via session#41;]
- end
+ Client[Arma Client Addons] --> Server[Arma Server Addons]
+ Server --> Bridge[Extension Bridge]
+ Bridge --> Extension[Rust arma-rs Extension]
+ Extension --> Services[Service Layer]
+ Services --> Repositories[Repository Traits]
+ Repositories --> Surreal[(SurrealDB)]
+```
+
+## Persistence Startup
+
+```mermaid
+sequenceDiagram
+ participant Arma as Arma Server
+ participant Ext as Forge Extension
+ participant Db as SurrealDB
+
+ Arma->>Ext: init
+ Ext->>Db: connect
+ Ext->>Db: apply schema modules
+ Db-->>Ext: ready
+ Arma->>Ext: status
+ Ext-->>Arma: connected
+```
+
+## Data Access
+
+```mermaid
+sequenceDiagram
+ participant SQF as SQF Addon
+ participant Ext as Extension Command
+ participant Service as Service
+ participant Repo as Repository
+ participant Db as SurrealDB
+
+ SQF->>Ext: domain command
+ Ext->>Service: validate and execute
+ Service->>Repo: repository call
+ Repo->>Db: query/upsert/delete
+ Db-->>Repo: result
+ Repo-->>Service: domain model
+ Service-->>Ext: response
+ Ext-->>SQF: serialized result
```
diff --git a/Cargo.toml b/Cargo.toml
index 080237f..d08f21f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -12,7 +12,6 @@ resolver = "3"
[workspace.dependencies]
arma-rs = { version = "1.11.15", features = ["chrono", "serde_json", "uuid"] }
chrono = "0.4.42"
-redis = "1.0.0-rc.1"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
tokio = { version = "1.47.1", features = ["full"] }
diff --git a/README.md b/README.md
index c97d5c2..2902ee8 100644
--- a/README.md
+++ b/README.md
@@ -1,313 +1,54 @@
-# Forge Framework
+# Forge
-**Forge** is a high-performance, production-ready framework for Arma 3 persistent game servers, built with Rust and Redis for optimal performance and reliability.
+Forge is a framework for Arma 3 persistent game servers. It combines SQF
+addons, a Rust `arma-rs` extension, shared service crates, and web-based client
+interfaces for player data, organizations, banking, garages, lockers, phones,
+CAD, stores, and task workflows.
-## Overview
+## Storage
-Forge provides a complete solution for managing persistent player data, organizations, and game state in Arma 3 multiplayer environments. It combines the performance of Rust with the flexibility of Redis to deliver sub-millisecond response times while maintaining data consistency across server restarts.
-
-### Key Features
-
-- **๐ High Performance**: Sub-millisecond data access through intelligent caching
-- **๐ Data Integrity**: Strict validation and type safety at every layer
-- **๐๏ธ Clean Architecture**: Layered design following SOLID principles
-- **๐ฆ Modular Design**: Easy to extend with new entities and features
-- **๐ Real-time Sync**: Automatic state synchronization across all clients
-- **๐พ Persistent Storage**: Redis-backed storage with automatic failover
-- **๐งช Testable**: Mock-friendly architecture for comprehensive testing
-
-## Architecture
-
-Forge follows a **layered architecture** pattern:
-
-```mermaid
-graph TD
- Extension[Extension Layer
ArmA 3 Interface <---> Rust]
- Services[Services Layer
#40;Business Logic#41;]
- Repositories[Repositories Layer
#40;Data Persistence#41;]
- Models[Models Layer
#40;Data Structures & Validation#41;]
-
- Extension --> Services
- Services --> Repositories
- Repositories --> Models
-```
-
-**Communication Flow**:
-
-- **Clients** โ Use events (`CBA_Events`) to communicate with server
-- **Server** โ Calls Rust extension via `callExtension`
-- **Extension** โ Manages Redis connection pool and data operations
-
-For detailed architecture information, see [Diagram](Architecture_Diagram.md).
-
-## Project Structure
-
-```
-forge/
-โโโ arma/
-โ โโโ client/ # Client-side SQF mod
-โ โ โโโ addons/
-โ โ โ โโโ main/ # Core initialization & config
-โ โ โ โโโ common/ # Shared utilities & helpers
-โ โ โ โโโ actor/ # Actor/player UI, class & events
-โ โ โ โโโ org/ # Organization UI, class & events
-โ โ โ โโโ bank/ # Banking UI, class & events
-โ โ โโโ include/ # Header files
-โ โ โโโ tools/ # Build tools
-โ โโโ server/
-โ โ โโโ addons/
-โ โ โ โโโ main/ # Core initialization & config
-โ โ โ โโโ common/ # Shared utilities & helpers
-โ โ โ โโโ actor/ # Actor/player Registry, Store & events
-โ โ โ โโโ org/ # Organization Registry, Store & events
-โ โ โโโ include/ # Header files
-โ โ โโโ tools/ # Build tools
-โ โ โโโ extension/ # Rust extension (Arma 3 interface)
-โ โ โโโ src/
-โ โ โ โโโ actor.rs # Actor/player commands
-โ โ โ โโโ org.rs # Organization commands
-โ โ โ โโโ redis/ # Redis operations module
-โ โ โ โโโ adapters/ # Repository adapters
-โ โ โโโ README.md
-โโโ lib/
-โ โโโ models/ # Data structures & validation
-โ โโโ repositories/ # Data persistence layer
-โ โโโ services/ # Business logic layer
-โ โโโ shared/ # Common utilities & traits
-โ โโโ README.md
-โโโ FORGE_Architecture_Diagram.md
-```
-
-## Quick Start
-
-### Prerequisites
-
-- Rust 1.70+ with `cargo`
-- Redis 6.0+
-- HEMTT
-
-1. Clone the repository from Gitea
-2. Install HEMTT
- The latest version of HEMTT can be installed by running:
-
-```cmd
-winget install hemtt
-```
-
-### Coding Guidelines
-
-This mod follows the same coding guidelines as the ACE3 mod, which can be found [here](https://ace3.acemod.org/wiki/development/coding-guidelines).
-
-### Building the Extension
-
-```bash
-# Build for release
-cargo build --release
-
-# The compiled extension will be at:
-# target/release/forge_server.dll (Windows)
-# target/release/forge_server.so (Linux)
-```
-
-### Configuration
-
-Create `@forge_server/config.toml`:
+Durable persistence is backed by SurrealDB. The server extension loads schema
+modules at startup and routes domain repositories through the SurrealDB client.
```toml
-[redis]
-host = "127.0.0.1"
-port = 6379
-password = "" # Optional
-max_connections = 10
-min_connections = 2
-idle_timeout = 300
+[surreal]
+endpoint = "127.0.0.1:8000"
+namespace = "forge"
+database = "main"
+username = "root"
+password = "root"
+connect_timeout_ms = 5000
```
-### SQF Usage
+## Workspace
+
+```text
+arma/
+ client/ Client-side addons and browser UIs
+ server/ Server-side addons and extension crate
+bin/
+ icom/ Interprocess communication helper
+lib/
+ models/ Shared domain models
+ repositories/ Repository traits and in-memory test stores
+ services/ Domain business logic
+ shared/ Cross-crate helpers
+tools/ Web UI build tooling
+```
+
+## Common Commands
+
+```powershell
+cargo test
+npm run build:webui
+.\build-arma.ps1
+```
+
+## Extension Status
```sqf
-// Create an actor
-private _data = createHashMapFromArray [
- ["name", "John Doe"],
- ["bank", 1000],
- ["level", 1]
-];
-private _result = "forge_server" callExtension ["actor:create", [getPlayerUID player, toJSON _data]];
-
-// Get actor data
-private _result = "forge_server" callExtension ["actor:get", [getPlayerUID player]];
-private _actorData = fromJSON (_result select 0);
-
-// Update actor
-private _update = createHashMapFromArray [["bank", 1500]];
-"forge_server" callExtension ["actor:update", [getPlayerUID player, toJSON _update]];
+"forge_server" callExtension ["status", []];
+"forge_server" callExtension ["surreal:status", []];
```
-## Core Modules
-
-### Models
-
-Defines strict data structures with built-in validation:
-
-- `Actor`: Player data (stats, inventory, position)
-- `Org`: Organization/clan data (members, roles, metadata)
-
-[Documentation](lib/models/README.md)
-
-### Repositories
-
-Manages data persistence with Redis:
-
-- Hash-based storage for structured data
-- Set-based storage for collections
-- Generic over Redis client implementations
-
-[Documentation](lib/repositories/README.md)
-
-### Services
-
-Implements business logic and orchestration:
-
-- Get-or-create patterns
-- Data validation and transformation
-- Complex workflows
-
-[Documentation](lib/services/README.md)
-
-### Extension
-
-Arma 3 interface layer:
-
-- Command routing and parsing
-- Session management
-- Error handling and logging
-
-[Documentation](arma/server/extension/README.md)
-
-### Client Mod
-
-Client-side SQF addon that provides:
-
-- **UI Components**: Player interfaces for inventory, organizations, banking
-- **Event Handlers**: CBA event listeners for server communication
-- **Optimistic Caching**: Local data caching for instant UI updates
-- **State Management**: Client-side state synchronization
-- **Input Validation**: Client-side validation before server requests
-
-The client mod communicates with the server using **CBA Events**, ensuring:
-
-- No direct extension calls from clients (security)
-- Event-driven architecture for scalability
-- Automatic state synchronization across all clients
-- Reduced server load through client-side caching
-
-## Available Commands
-
-### Actor Commands
-
-| Command | Description |
-| -------------- | -------------------------- |
-| `actor:get` | Retrieve actor data by UID |
-| `actor:create` | Create a new actor |
-| `actor:update` | Update actor fields |
-| `actor:exists` | Check if actor exists |
-| `actor:delete` | Delete actor data |
-
-### Organization Commands
-
-| Command | Description |
-| ------------------- | ------------------------------- |
-| `org:get` | Retrieve organization data |
-| `org:create` | Create a new organization |
-| `org:update` | Update organization fields |
-| `org:exists` | Check if organization exists |
-| `org:delete` | Delete organization |
-| `org:add_member` | Add member to organization |
-| `org:remove_member` | Remove member from organization |
-| `org:get_members` | Get all organization members |
-
-### Redis Operations
-
-Direct Redis operations for advanced use cases:
-
-- **Common**: Key-value operations (set, get, incr, decr, del)
-- **Hash**: Structured data (hset, hget, hgetall, hdel)
-- **List**: Ordered collections (lpush, rpush, lrange, lpop, rpop)
-- **Set**: Unique collections (sadd, smembers, srem, sismember)
-
-[Documentation](arma/server/extension/src/redis/README.md)
-
-## Performance
-
-- **Hot Cache (Server)**: < 1ms (HashMap lookup)
-- **Cold Storage (Redis)**: 1-5ms (Network + Redis query)
-- **Cache Hit Ratio**: ~95% for active players
-- **Memory Usage**: ~1KB per active player (server), ~2KB per player (Redis)
-
-## Contributing
-
-We welcome contributions! Please see the contributing guides for each layer:
-
-- [Extension Contributing Guide](arma/server/extension/README.md#contributing)
-- [Services Contributing Guide](lib/services/README.md#contributing)
-- [Repositories Contributing Guide](lib/repositories/README.md#contributing)
-- [Models Contributing Guide](lib/models/README.md#contributing)
-- [Library Contributing Guide](lib/README.md#contributing)
-- [Adapter Contributing Guide](arma/server/extension/src/adapters/#contributing)
-
-### Development Workflow
-
-1. **Define Model**: Create data structure with validation
-2. **Create Repository**: Implement persistence layer
-3. **Build Service**: Add business logic
-4. **Expose in Extension**: Create SQF-callable commands
-5. **Test**: Verify each layer independently
-
-## Error Handling
-
-All commands return consistent error messages:
-
-```sqf
-private _result = "forge_server" callExtension ["actor:get", ["invalid_uid"]];
-private _response = _result select 0;
-
-if (_response find "Error:" == 0) then {
- diag_log format ["Operation failed: %1", _response];
-} else {
- private _data = fromJSON _response;
- // Use data
-};
-```
-
-## Logging
-
-Logs are automatically created in `@forge_server/logs/`:
-
-- `actor.log` - Actor operations
-- `org.log` - Organization operations
-- `redis.log` - Redis connection and operations
-- `debug.log` - General debug information
-
-## License
-
-View the License [here](LICENSE.md).
-
-## Support
-
-- **Issues**: [Gitea Issues](https://gitea.innovativedevsolutions.org/IDSolutions/forge/issues)
-- **Documentation**: See individual module READMEs
-- **Architecture**: [Diagram](Architecture_Diagram.md)
-
-## Roadmap
-
-- [ ] Admin system
-- [ ] Arsenal system
-- [ ] Banking system
-- [ ] Economy system
-- [ ] Garage system
-- [ ] Locker system
-- [ ] Mission template
-
----
-
-Built using **Rust**, **Redis**, and **Arma 3**
+Both commands report the persistence connection state.
diff --git a/arma/client/addons/actor/functions/fnc_handleUIEvents.sqf b/arma/client/addons/actor/functions/fnc_handleUIEvents.sqf
index 32dfca8..64b30a3 100644
--- a/arma/client/addons/actor/functions/fnc_handleUIEvents.sqf
+++ b/arma/client/addons/actor/functions/fnc_handleUIEvents.sqf
@@ -4,7 +4,7 @@
* File: fnc_handleUIEvents.sqf
* Author: IDSolutions
* Date: 2026-01-28
- * Last Update: 2026-03-28
+ * Last Update: 2026-04-06
* Public: No
*
* Description:
@@ -41,7 +41,7 @@ switch (_event) do {
case "actor::open::vgarage": { [] spawn EFUNC(garage,openVG); };
case "actor::open::org": { [] spawn EFUNC(org,openUI); };
case "actor::open::vlocker": { [FORGE_Locker_Box, player, false] spawn AFUNC(arsenal,openBox) };
- case "actor::open::phone": { hint "Phone interaction is not yet implemented."; };
+ case "actor::open::phone": { [] spawn EFUNC(phone,openUI); };
case "actor::open::iplayer": { hint "Player interaction is not yet implemented." };
case "actor::open::store": { [] spawn EFUNC(store,openUI); };
default { hint format ["Unhandled UI event: %1", _event]; };
diff --git a/arma/client/addons/phone/$PBOPREFIX$ b/arma/client/addons/phone/$PBOPREFIX$
new file mode 100644
index 0000000..6193056
--- /dev/null
+++ b/arma/client/addons/phone/$PBOPREFIX$
@@ -0,0 +1 @@
+forge\forge_client\addons\phone
diff --git a/arma/client/addons/phone/CfgEventHandlers.hpp b/arma/client/addons/phone/CfgEventHandlers.hpp
new file mode 100644
index 0000000..c6e25db
--- /dev/null
+++ b/arma/client/addons/phone/CfgEventHandlers.hpp
@@ -0,0 +1,19 @@
+class Extended_PreStart_EventHandlers {
+ class ADDON {
+ init = QUOTE(call COMPILE_SCRIPT(XEH_preStart));
+ };
+};
+
+class Extended_PreInit_EventHandlers {
+ class ADDON {
+ init = QUOTE(call COMPILE_SCRIPT(XEH_preInit));
+ clientInit = QUOTE(call COMPILE_SCRIPT(XEH_preInitClient));
+ };
+};
+
+class Extended_PostInit_EventHandlers {
+ class ADDON {
+ init = QUOTE(call COMPILE_SCRIPT(XEH_postInit));
+ clientInit = QUOTE(call COMPILE_SCRIPT(XEH_postInitClient));
+ };
+};
diff --git a/arma/client/addons/phone/README.md b/arma/client/addons/phone/README.md
new file mode 100644
index 0000000..756d2e4
--- /dev/null
+++ b/arma/client/addons/phone/README.md
@@ -0,0 +1,4 @@
+forge_client_phone
+===================
+
+This addon provides the phone user interface and functionality for the in-game phone system. It handles all phone-related features including the UI display, interactions, and core phone operations.
\ No newline at end of file
diff --git a/arma/client/addons/phone/XEH_PREP.hpp b/arma/client/addons/phone/XEH_PREP.hpp
new file mode 100644
index 0000000..15fff64
--- /dev/null
+++ b/arma/client/addons/phone/XEH_PREP.hpp
@@ -0,0 +1,3 @@
+PREP(handleUIEvents);
+PREP(initClass);
+PREP(openUI);
diff --git a/arma/client/addons/phone/XEH_postInit.sqf b/arma/client/addons/phone/XEH_postInit.sqf
new file mode 100644
index 0000000..421c54b
--- /dev/null
+++ b/arma/client/addons/phone/XEH_postInit.sqf
@@ -0,0 +1 @@
+#include "script_component.hpp"
diff --git a/arma/client/addons/phone/XEH_postInitClient.sqf b/arma/client/addons/phone/XEH_postInitClient.sqf
new file mode 100644
index 0000000..f3292be
--- /dev/null
+++ b/arma/client/addons/phone/XEH_postInitClient.sqf
@@ -0,0 +1,340 @@
+#include "script_component.hpp"
+
+[{
+ GETVAR(player,FORGE_isLoaded,false)
+}, {
+ [QGVAR(initPhone), []] call CFUNC(localEvent);
+}] call CFUNC(waitUntilAndExecute);
+
+if (isNil QGVAR(PhoneClass)) then { [] call FUNC(initClass); };
+
+[QGVAR(initPhone), {
+ GVAR(PhoneClass) call ["init", []];
+
+ ["forge_server_phone_requestInitPhone", [getPlayerUID player, createHashMap]] call CFUNC(serverEvent);
+ ["forge_server_phone_requestRefreshContacts", [getPlayerUID player, player]] call CFUNC(serverEvent);
+}] call CFUNC(addEventHandler);
+
+[QGVAR(responseSyncPhone), {
+ params [["_data", createHashMap, [createHashMap]]];
+
+ GVAR(PhoneClass) call ["sync", [_data]];
+}] call CFUNC(addEventHandler);
+
+// Contact Management Response Events
+[QGVAR(responseAddContact), {
+ params [["_success", false, [false]]];
+
+ if (_success) then {
+ [QEGVAR(notifications,recieveNotification), ["success", "Contact Added", "Contact added successfully", 3000]] call CFUNC(localEvent);
+ [QGVAR(refreshUI), []] call CFUNC(localEvent);
+ } else {
+ [QEGVAR(notifications,recieveNotification), ["danger", "Contact Error", "Failed to add contact", 4000]] call CFUNC(localEvent);
+ };
+}] call CFUNC(addEventHandler);
+
+[QGVAR(responseAddContactByPhone), {
+ params [["_success", false, [false]], ["_phoneNumber", "", [""]]];
+
+ if (_success) then {
+ [QEGVAR(notifications,recieveNotification), ["success", "Contact Added", format ["Contact with phone %1 added successfully", _phoneNumber], 3000]] call CFUNC(localEvent);
+ [QGVAR(refreshUI), []] call CFUNC(localEvent);
+ } else {
+ [QEGVAR(notifications,recieveNotification), ["warning", "Contact Not Found", format ["Player with phone %1 not found", _phoneNumber], 4000]] call CFUNC(localEvent);
+ };
+}] call CFUNC(addEventHandler);
+
+[QGVAR(responseAddContactByEmail), {
+ params [["_success", false, [false]], ["_email", "", [""]]];
+
+ if (_success) then {
+ [QEGVAR(notifications,recieveNotification), ["success", "Contact Added", format ["Contact with email %1 added successfully", _email], 3000]] call CFUNC(localEvent);
+ [QGVAR(refreshUI), []] call CFUNC(localEvent);
+ } else {
+ [QEGVAR(notifications,recieveNotification), ["warning", "Contact Not Found", format ["Player with email %1 not found", _email], 4000]] call CFUNC(localEvent);
+ };
+}] call CFUNC(addEventHandler);
+
+[QGVAR(responseRemoveContact), {
+ params [["_success", false, [false]], ["_contactUid", "", [""]]];
+
+ if (_success) then {
+ [QEGVAR(notifications,recieveNotification), ["success", "Contact Removed", "Contact removed successfully", 3000]] call CFUNC(localEvent);
+ [QGVAR(refreshUI), []] call CFUNC(localEvent);
+ } else {
+ [QEGVAR(notifications,recieveNotification), ["danger", "Contact Error", "Failed to remove contact", 4000]] call CFUNC(localEvent);
+ };
+}] call CFUNC(addEventHandler);
+
+[QGVAR(responseRefreshContacts), {
+ params [["_contacts", [], [[]]]];
+
+ diag_log format ["[FORGE:Client:Phone] Contacts refreshed: %1 contacts", count _contacts];
+
+ [QGVAR(updateContacts), [_contacts]] call CFUNC(localEvent);
+}] call CFUNC(addEventHandler);
+
+[QGVAR(responseGetContacts), {
+ params [["_contactUids", [], [[]]]];
+
+ diag_log format ["[FORGE:Client:Phone] Got contact UIDs: %1", _contactUids];
+}] call CFUNC(addEventHandler);
+
+// Messaging Response Events
+[QGVAR(responseMessageSent), {
+ params [["_messageObj", createHashMap, [createHashMap]]];
+
+ diag_log format ["[FORGE:Client:Phone] Message sent: %1", _messageObj];
+
+ [QGVAR(updateMessageSent), [_messageObj]] call CFUNC(localEvent);
+}] call CFUNC(addEventHandler);
+
+[QGVAR(responseMessageReceived), {
+ params [["_messageObj", createHashMap, [createHashMap]]];
+
+ private _fromUid = _messageObj get "from";
+ private _message = _messageObj get "message";
+ private _contacts = player getVariable ["FORGE_Contacts", []];
+ private _senderName = "Unknown";
+
+ {
+ if ((_x get "uid") isEqualTo _fromUid) exitWith {
+ _senderName = _x get "name";
+ };
+ } forEach _contacts;
+
+ [QEGVAR(notifications,recieveNotification), ["info", "New Message", format ["From %1", _senderName], 4000]] call CFUNC(localEvent);
+
+ diag_log format ["[FORGE:Client:Phone] Message received from %1: %2", _fromUid, _message];
+
+ [QGVAR(updateMessageReceived), [_messageObj]] call CFUNC(localEvent);
+}] call CFUNC(addEventHandler);
+
+[QGVAR(responseSendMessage), {
+ params [["_success", false, [false]]];
+
+ if (_success) then {
+ [QEGVAR(notifications,recieveNotification), ["success", "Message Sent", "Message sent successfully", 2000]] call CFUNC(localEvent);
+ } else {
+ [QEGVAR(notifications,recieveNotification), ["danger", "Message Failed", "Failed to send message", 4000]] call CFUNC(localEvent);
+ };
+}] call CFUNC(addEventHandler);
+
+[QGVAR(responseGetMessages), {
+ params [["_messages", [], [[]]]];
+
+ diag_log format ["[FORGE:Client:Phone] Got %1 messages", count _messages];
+
+ [QGVAR(updateMessages), [_messages]] call CFUNC(localEvent);
+}] call CFUNC(addEventHandler);
+
+[QGVAR(responseGetMessageThread), {
+ params [["_messages", [], [[]]], ["_otherUid", "", [""]]];
+
+ diag_log format ["[FORGE:Client:Phone] Got message thread with %1: %2 messages", _otherUid, count _messages];
+
+ [QGVAR(updateMessageThread), [_messages, _otherUid]] call CFUNC(localEvent);
+}] call CFUNC(addEventHandler);
+
+[QGVAR(responseMarkMessageRead), {
+ params [["_success", false, [false]], ["_messageId", "", [""]]];
+
+ if (_success) then { diag_log format ["[FORGE:Client:Phone] Message %1 marked as read", _messageId]; };
+}] call CFUNC(addEventHandler);
+
+[QGVAR(responseMessageRead), {
+ params [["_messageId", "", [""]]];
+
+ diag_log format ["[FORGE:Client:Phone] Message %1 marked as read", _messageId];
+
+ [QGVAR(updateMessageRead), [_messageId]] call CFUNC(localEvent);
+}] call CFUNC(addEventHandler);
+
+[QGVAR(responseDeleteMessage), {
+ params [["_success", false, [false]], ["_messageId", "", [""]]];
+
+ if (_success) then {
+ diag_log format ["[FORGE:Client:Phone] Message %1 deleted", _messageId];
+ [QGVAR(updateMessageDeleted), [_messageId]] call CFUNC(localEvent);
+ } else {
+ [QEGVAR(notifications,recieveNotification), ["danger", "Message Delete Failed", "Failed to delete message", 4000]] call CFUNC(localEvent);
+ };
+}] call CFUNC(addEventHandler);
+
+// Email Response Events
+[QGVAR(responseEmailSent), {
+ params [["_emailObj", createHashMap, [createHashMap]]];
+
+ diag_log format ["[FORGE:Client:Phone] Email sent: %1", _emailObj];
+
+ [QGVAR(updateEmailSent), [_emailObj]] call CFUNC(localEvent);
+}] call CFUNC(addEventHandler);
+
+[QGVAR(responseEmailReceived), {
+ params [["_emailObj", createHashMap, [createHashMap]]];
+
+ private _fromUid = _emailObj get "from";
+ private _subject = _emailObj get "subject";
+ private _contacts = player getVariable ["FORGE_Contacts", []];
+ private _senderName = "Unknown";
+
+ {
+ if ((_x get "uid") isEqualTo _fromUid) exitWith {
+ _senderName = _x get "name";
+ };
+ } forEach _contacts;
+
+ [QEGVAR(notifications,recieveNotification), ["info", "New Email", format ["From %1: %2", _senderName, _subject], 4000]] call CFUNC(localEvent);
+
+ diag_log format ["[FORGE:Client:Phone] Email received from %1: %2", _fromUid, _subject];
+
+ [QGVAR(updateEmailReceived), [_emailObj]] call CFUNC(localEvent);
+}] call CFUNC(addEventHandler);
+
+[QGVAR(responseSendEmail), {
+ params [["_success", false, [false]]];
+
+ if (_success) then {
+ [QEGVAR(notifications,recieveNotification), ["success", "Email Sent", "Email sent successfully", 2000]] call CFUNC(localEvent);
+ } else {
+ [QEGVAR(notifications,recieveNotification), ["danger", "Email Failed", "Failed to send email", 4000]] call CFUNC(localEvent);
+ };
+}] call CFUNC(addEventHandler);
+
+[QGVAR(responseGetEmails), {
+ params [["_emails", [], [[]]]];
+
+ diag_log format ["[FORGE:Client:Phone] Got %1 emails", count _emails];
+
+ [QGVAR(updateEmails), [_emails]] call CFUNC(localEvent);
+}] call CFUNC(addEventHandler);
+
+[QGVAR(responseMarkEmailRead), {
+ params [["_success", false, [false]], ["_emailId", "", [""]]];
+
+ if (_success) then {
+ diag_log format ["[FORGE:Client:Phone] Email %1 marked as read", _emailId];
+ [QGVAR(updateEmailRead), [_emailId]] call CFUNC(localEvent);
+ };
+}] call CFUNC(addEventHandler);
+
+[QGVAR(responseEmailRead), {
+ params [["_emailId", "", [""]]];
+
+ diag_log format ["[FORGE:Client:Phone] Email %1 marked as read", _emailId];
+
+ [QGVAR(updateEmailRead), [_emailId]] call CFUNC(localEvent);
+}] call CFUNC(addEventHandler);
+
+[QGVAR(responseDeleteEmail), {
+ params [["_success", false, [false]], ["_emailId", "", [""]]];
+
+ if (_success) then {
+ diag_log format ["[FORGE:Client:Phone] Email %1 deleted", _emailId];
+ [QGVAR(updateEmailDeleted), [_emailId]] call CFUNC(localEvent);
+ } else {
+ [QEGVAR(notifications,recieveNotification), ["danger", "Email Delete Failed", "Failed to delete email", 4000]] call CFUNC(localEvent);
+ };
+}] call CFUNC(addEventHandler);
+
+// Cleanup Response Events
+[QGVAR(responseRemovePhone), {
+ params [["_success", false, [false]]];
+
+ if (_success) then { diag_log "[FORGE:Client:Phone] Phone data removed successfully"; };
+}] call CFUNC(addEventHandler);
+
+// UI Update Events (for internal use)
+[QGVAR(refreshUI), {
+ private _control = (uiNamespace getVariable ["RscPhone", displayNull]) displayCtrl 1001;
+
+ if (!isNull _control) then { _control ctrlWebBrowserAction ["ExecJS", "refreshContacts()"]; };
+}] call CFUNC(addEventHandler);
+
+[QGVAR(updateContacts), {
+ params [["_contacts", [], [[]]]];
+
+ private _control = (uiNamespace getVariable ["RscPhone", displayNull]) displayCtrl 1001;
+
+ if (!isNull _control) then { _control ctrlWebBrowserAction ["ExecJS", format ["updateContacts(%1)", (toJSON _contacts)]]; };
+}] call CFUNC(addEventHandler);
+
+[QGVAR(updateMessageSent), {
+ params [["_messageObj", createHashMap, [createHashMap]]];
+
+ private _control = (uiNamespace getVariable ["RscPhone", displayNull]) displayCtrl 1001;
+
+ if (!isNull _control) then { _control ctrlWebBrowserAction ["ExecJS", format ["updateMessageSent(%1)", (toJSON _messageObj)]]; };
+}] call CFUNC(addEventHandler);
+
+[QGVAR(updateMessageReceived), {
+ params [["_messageObj", createHashMap, [createHashMap]]];
+
+ private _control = (uiNamespace getVariable ["RscPhone", displayNull]) displayCtrl 1001;
+
+ if (!isNull _control) then { _control ctrlWebBrowserAction ["ExecJS", format ["updateMessageReceived(%1)", (toJSON _messageObj)]]; };
+}] call CFUNC(addEventHandler);
+
+[QGVAR(updateMessages), {
+ params [["_messages", [], [[]]]];
+
+ private _control = (uiNamespace getVariable ["RscPhone", displayNull]) displayCtrl 1001;
+
+ if (!isNull _control) then { _control ctrlWebBrowserAction ["ExecJS", format ["updateMessages(%1)", (toJSON _messages)]]; };
+}] call CFUNC(addEventHandler);
+
+[QGVAR(updateMessageThread), {
+ params [["_messages", [], [[]]], ["_otherUid", "", [""]]];
+
+ private _control = (uiNamespace getVariable ["RscPhone", displayNull]) displayCtrl 1001;
+
+ if (!isNull _control) then { _control ctrlWebBrowserAction ["ExecJS", format ["updateMessageThread(%1, %2)", (toJSON _messages), (toJSON _otherUid)]]; };
+}] call CFUNC(addEventHandler);
+
+[QGVAR(updateMessageDeleted), {
+ params [["_messageId", "", [""]]];
+
+ private _control = (uiNamespace getVariable ["RscPhone", displayNull]) displayCtrl 1001;
+
+ if (!isNull _control) then { _control ctrlWebBrowserAction ["ExecJS", format ["updateMessageDeleted(%1)", (toJSON _messageId)]]; };
+}] call CFUNC(addEventHandler);
+
+[QGVAR(updateEmailSent), {
+ params [["_emailObj", createHashMap, [createHashMap]]];
+
+ private _control = (uiNamespace getVariable ["RscPhone", displayNull]) displayCtrl 1001;
+
+ if (!isNull _control) then { _control ctrlWebBrowserAction ["ExecJS", format ["updateEmailSent(%1)", (toJSON _emailObj)]]; };
+}] call CFUNC(addEventHandler);
+
+[QGVAR(updateEmailReceived), {
+ params [["_emailObj", createHashMap, [createHashMap]]];
+
+ private _control = (uiNamespace getVariable ["RscPhone", displayNull]) displayCtrl 1001;
+
+ if (!isNull _control) then { _control ctrlWebBrowserAction ["ExecJS", format ["updateEmailReceived(%1)", (toJSON _emailObj)]]; };
+}] call CFUNC(addEventHandler);
+
+[QGVAR(updateEmails), {
+ params [["_emails", [], [[]]]];
+
+ private _control = (uiNamespace getVariable ["RscPhone", displayNull]) displayCtrl 1001;
+
+ if (!isNull _control) then { _control ctrlWebBrowserAction ["ExecJS", format ["updateEmails(%1)", (toJSON _emails)]]; };
+}] call CFUNC(addEventHandler);
+
+[QGVAR(updateEmailRead), {
+ params [["_emailId", "", [""]]];
+
+ private _control = (uiNamespace getVariable ["RscPhone", displayNull]) displayCtrl 1001;
+
+ if (!isNull _control) then { _control ctrlWebBrowserAction ["ExecJS", format ["updateEmailRead(%1)", (toJSON _emailId)]]; };
+}] call CFUNC(addEventHandler);
+
+[QGVAR(updateEmailDeleted), {
+ params [["_emailId", "", [""]]];
+
+ private _control = (uiNamespace getVariable ["RscPhone", displayNull]) displayCtrl 1001;
+
+ if (!isNull _control) then { _control ctrlWebBrowserAction ["ExecJS", format ["updateEmailDeleted(%1)", (toJSON _emailId)]]; };
+}] call CFUNC(addEventHandler);
diff --git a/arma/client/addons/phone/XEH_preInit.sqf b/arma/client/addons/phone/XEH_preInit.sqf
new file mode 100644
index 0000000..814e573
--- /dev/null
+++ b/arma/client/addons/phone/XEH_preInit.sqf
@@ -0,0 +1,9 @@
+#include "script_component.hpp"
+
+PREP_RECOMPILE_START;
+#include "XEH_PREP.hpp"
+PREP_RECOMPILE_END;
+
+private _category = [QUOTE(MOD_NAME), LLSTRING(displayName)];
+
+#include "initKeybinds.inc.sqf"
diff --git a/arma/client/addons/phone/XEH_preInitClient.sqf b/arma/client/addons/phone/XEH_preInitClient.sqf
new file mode 100644
index 0000000..421c54b
--- /dev/null
+++ b/arma/client/addons/phone/XEH_preInitClient.sqf
@@ -0,0 +1 @@
+#include "script_component.hpp"
diff --git a/arma/client/addons/phone/XEH_preStart.sqf b/arma/client/addons/phone/XEH_preStart.sqf
new file mode 100644
index 0000000..a51262a
--- /dev/null
+++ b/arma/client/addons/phone/XEH_preStart.sqf
@@ -0,0 +1,2 @@
+#include "script_component.hpp"
+#include "XEH_PREP.hpp"
diff --git a/arma/client/addons/phone/config.cpp b/arma/client/addons/phone/config.cpp
new file mode 100644
index 0000000..a80ac19
--- /dev/null
+++ b/arma/client/addons/phone/config.cpp
@@ -0,0 +1,21 @@
+#include "script_component.hpp"
+
+class CfgPatches {
+ class ADDON {
+ author = AUTHOR;
+ authors[] = {"J. Schmidt"};
+ url = ECSTRING(main,url);
+ name = COMPONENT_NAME;
+ requiredVersion = REQUIRED_VERSION;
+ requiredAddons[] = {
+ "forge_client_main"
+ };
+ units[] = {};
+ weapons[] = {};
+ VERSION_CONFIG;
+ };
+};
+
+#include "CfgEventHandlers.hpp"
+#include "ui\RscCommon.hpp"
+#include "ui\RscPhone.hpp"
diff --git a/arma/client/addons/phone/functions/fnc_handleUIEvents.sqf b/arma/client/addons/phone/functions/fnc_handleUIEvents.sqf
new file mode 100644
index 0000000..3b66a67
--- /dev/null
+++ b/arma/client/addons/phone/functions/fnc_handleUIEvents.sqf
@@ -0,0 +1,351 @@
+#include "..\script_component.hpp"
+
+/*
+ * Author: IDSolutions
+ * Handles UI events.
+ *
+ * Arguments:
+ * None
+ *
+ * Return Value:
+ * None
+ *
+ * Example:
+ * [] call forge_client_phone_fnc_handleUIEvents;
+ *
+ * Public: No
+ */
+
+params ["_control", "_isConfirmDialog", "_message"];
+
+private _alert = fromJSON _message;
+private _event = _alert get "event";
+private _data = _alert get "data";
+
+// diag_log format ["[FORGE:Client:Phone] Handling UI event: %1 with data: %2", _event, _data];
+
+switch (_event) do {
+ case "phone::get::player": {
+ private _uid = getPlayerUID player;
+ _control ctrlWebBrowserAction ["ExecJS", format ["setPlayerUid(%1)", (toJSON _uid)]];
+ };
+ case "phone::get::theme": {
+ private _isDark = profileNamespace getVariable ["FORGE_Phone_isDark", true];
+ private _theme = ["light", "dark"] select (_isDark);
+
+ _control ctrlWebBrowserAction ["ExecJS", format ["setTheme(%1)", (toJSON _theme)]];
+ };
+ case "phone::get::contacts": {
+ private _contacts = player getVariable ["FORGE_Contacts", []];
+
+ _control ctrlWebBrowserAction ["ExecJS", format ["loadContacts(%1)", (toJSON _contacts)]];
+ ["forge_server_phone_requestRefreshContacts", [getPlayerUID player, player]] call CFUNC(serverEvent);
+ };
+ case "phone::set::theme": {
+ private _isDark = _data get "isDark";
+
+ profileNamespace setVariable ["FORGE_Phone_isDark", _isDark];
+ };
+ case "phone::add::contact": {
+ private _contactPhone = _data get "phone";
+
+ if (_contactPhone isNotEqualTo "") then {
+ ["forge_server_phone_requestAddContactByPhone", [getPlayerUID player, _contactPhone, player]] call CFUNC(serverEvent);
+ } else {
+ diag_log "[FORGE:Client:Phone] No phone number provided for contact addition";
+ };
+ };
+ case "phone::add::contact::by::phone": {
+ private _phoneNumber = _data get "phone";
+
+ if (_phoneNumber isNotEqualTo "") then {
+ ["forge_server_phone_requestAddContactByPhone", [getPlayerUID player, _phoneNumber, player]] call CFUNC(serverEvent);
+ } else {
+ diag_log "[FORGE:Client:Phone] No phone number provided";
+ };
+ };
+ case "phone::add::contact::by::email": {
+ private _email = _data get "email";
+
+ if (_email isNotEqualTo "") then {
+ ["forge_server_phone_requestAddContactByEmail", [getPlayerUID player, _email, player]] call CFUNC(serverEvent);
+ } else {
+ diag_log "[FORGE:Client:Phone] No email provided";
+ };
+ };
+ case "phone::remove::contact": {
+ private _contactUid = _data get "uid";
+
+ if (_contactUid isNotEqualTo "") then {
+ ["forge_server_phone_requestRemoveContact", [getPlayerUID player, _contactUid, player]] call CFUNC(serverEvent);
+ } else {
+ diag_log "[FORGE:Client:Phone] No contact UID provided for removal";
+ };
+ };
+ case "phone::refresh::contacts": {
+ ["forge_server_phone_requestRefreshContacts", [getPlayerUID player, player]] call CFUNC(serverEvent);
+ };
+ case "phone::send::message": {
+ private _contactName = _data get "contactName";
+ private _messageData = _data get "message";
+ private _messageText = _messageData get "text";
+ private _toUid = _data get "toUid";
+
+ if (_toUid isNotEqualTo "") then {
+ ["forge_server_phone_requestSendMessage", [getPlayerUID player, _toUid, _messageText, player]] call CFUNC(serverEvent);
+ } else {
+ diag_log format ["[FORGE:Client:Phone] No recipient UID provided for message to %1", _contactName];
+ };
+ };
+ case "phone::get::messages": {
+ ["forge_server_phone_requestGetMessages", [getPlayerUID player, player]] call CFUNC(serverEvent);
+ };
+ case "phone::get::message::thread": {
+ private _otherUid = _data get "otherUid";
+
+ if (_otherUid isNotEqualTo "") then {
+ ["forge_server_phone_requestGetMessageThread", [getPlayerUID player, _otherUid, player]] call CFUNC(serverEvent);
+ } else {
+ diag_log "[FORGE:Client:Phone] No other UID provided for message thread";
+ };
+ };
+ case "phone::mark::message::read": {
+ private _messageId = _data get "messageId";
+
+ if (_messageId isNotEqualTo "") then {
+ ["forge_server_phone_requestMarkMessageRead", [getPlayerUID player, _messageId, player]] call CFUNC(serverEvent);
+ } else {
+ diag_log "[FORGE:Client:Phone] No message ID provided for mark read";
+ };
+ };
+ case "phone::delete::message": {
+ private _messageId = _data get "messageId";
+
+ if (_messageId isNotEqualTo "") then {
+ ["forge_server_phone_requestDeleteMessage", [getPlayerUID player, _messageId, player]] call CFUNC(serverEvent);
+ } else {
+ diag_log "[FORGE:Client:Phone] No message ID provided for delete";
+ };
+ };
+ case "phone::send::email": {
+ private _toUid = _data get "toUid";
+ private _subject = _data get "subject";
+ private _body = _data get "body";
+ if (_subject isEqualTo "") then { _subject = "No subject"; };
+
+ if (_toUid isNotEqualTo "" && _body isNotEqualTo "") then {
+ diag_log format ["[FORGE:Client:Phone] Sending email to %1 subject length %2 body length %3", _toUid, count _subject, count _body];
+ ["forge_server_phone_requestSendEmail", [getPlayerUID player, _toUid, _subject, _body, player]] call CFUNC(serverEvent);
+ } else {
+ diag_log "[FORGE:Client:Phone] Missing required email parameters";
+ };
+ };
+ case "phone::get::emails": {
+ ["forge_server_phone_requestGetEmails", [getPlayerUID player, player]] call CFUNC(serverEvent);
+ };
+ case "phone::mark::email::read": {
+ private _emailId = _data get "emailId";
+
+ if (_emailId isNotEqualTo "") then {
+ ["forge_server_phone_requestMarkEmailRead", [getPlayerUID player, _emailId, player]] call CFUNC(serverEvent);
+ } else {
+ diag_log "[FORGE:Client:Phone] No email ID provided for mark read";
+ };
+ };
+ case "phone::delete::email": {
+ private _emailId = _data get "emailId";
+
+ if (_emailId isNotEqualTo "") then {
+ ["forge_server_phone_requestDeleteEmail", [getPlayerUID player, _emailId, player]] call CFUNC(serverEvent);
+ } else {
+ diag_log "[FORGE:Client:Phone] No email ID provided for delete";
+ };
+ };
+ case "phone::get::notes": {
+ private _notes = GVAR(PhoneClass) call ["getAllNotes", []];
+
+ _control ctrlWebBrowserAction ["ExecJS", format ["loadNotes(%1)", (toJSON _notes)]];
+ };
+ case "phone::save::note": {
+ private _success = GVAR(PhoneClass) call ["addNote", [_data]];
+ _success
+ };
+ case "phone::delete::note": {
+ private _noteId = _data get "id";
+
+ private _success = GVAR(PhoneClass) call ["deleteNote", [_noteId]];
+ _success
+ };
+ case "phone::get::events": {
+ private _events = profileNamespace getVariable ["FORGE_Phone_Events", []];
+
+ _control ctrlWebBrowserAction ["ExecJS", format ["loadCalendarEvents(%1)", (toJSON _events)]];
+ };
+ case "phone::save::event": {
+ private _eventId = _data get "id";
+ private _eventTitle = _data get "title";
+
+ private _events = profileNamespace getVariable ["FORGE_Phone_Events", []];
+ private _existingIndex = -1;
+ {
+ private _existingId = _x get "id";
+ if (_existingId isEqualTo _eventId) then {
+ _existingIndex = _forEachIndex;
+ };
+ } forEach _events;
+
+ if (_existingIndex >= 0) then {
+ _events set [_existingIndex, _data];
+ diag_log format ["[PHONE] Updated event: %1 [ID: %2]", _eventTitle, _eventId];
+ } else {
+ _events pushBack _data;
+ diag_log format ["[PHONE] Added new event: %1 [ID: %2]", _eventTitle, _eventId];
+ };
+
+ profileNamespace setVariable ["FORGE_Phone_Events", _events];
+ diag_log format ["[PHONE] Saved events to profile. Total events: %1", count _events];
+ };
+ case "phone::delete::event": {
+ private _eventId = _data get "id";
+ private _events = profileNamespace getVariable ["FORGE_Phone_Events", []];
+
+ private _newEvents = [];
+ private _deleted = false;
+ {
+ private _existingId = _x get "id";
+ if (_existingId isEqualTo _eventId) then {
+ _deleted = true;
+ } else {
+ _newEvents pushBack _x;
+ };
+ } forEach _events;
+
+ if (_deleted) then {
+ profileNamespace setVariable ["FORGE_Phone_Events", _newEvents];
+ diag_log format ["[PHONE] Deleted calendar event [ID: %1]. Remaining events: %2", _eventId, count _newEvents];
+ } else {
+ diag_log format ["[PHONE] Calendar event not found for deletion [ID: %1]", _eventId];
+ };
+ };
+ case "phone::get::clocks": {
+ private _worldClocks = profileNamespace getVariable ["FORGE_Phone_WorldClocks", []];
+
+ _control ctrlWebBrowserAction ["ExecJS", format ["loadWorldClocks(%1)", (toJSON _worldClocks)]];
+ };
+ case "phone::save::clock": {
+ private _clockId = _data get "id";
+ private _timezone = _data get "timezone";
+ private _city = _data get "city";
+
+ private _worldClocks = profileNamespace getVariable ["FORGE_Phone_WorldClocks", []];
+ private _clockExists = false;
+ {
+ private _existingId = _x get "id";
+ private _existingTimezone = _x get "timezone";
+ if (_existingId isEqualTo _clockId || _existingTimezone isEqualTo _timezone) then {
+ _clockExists = true;
+ };
+ } forEach _worldClocks;
+
+ if (!_clockExists) then {
+ _worldClocks pushBack _data;
+ profileNamespace setVariable ["FORGE_Phone_WorldClocks", _worldClocks];
+
+ diag_log format ["[PHONE] Added world clock: %1 (%2) [ID: %3]. Total clocks: %4", _city, _timezone, _clockId, count _worldClocks];
+ } else {
+ diag_log format ["[PHONE] World clock already exists: %1 (%2) [ID: %3]. Skipping duplicate.", _city, _timezone, _clockId];
+ };
+ };
+ case "phone::delete::clock": {
+ private _clockId = _data get "id";
+
+ private _worldClocks = profileNamespace getVariable ["FORGE_Phone_WorldClocks", []];
+ private _newClocks = [];
+ private _deleted = false;
+ {
+ private _existingId = _x get "id";
+ if (_existingId isEqualTo _clockId) then {
+ _deleted = true;
+ } else {
+ _newClocks pushBack _x;
+ };
+ } forEach _worldClocks;
+
+ if (_deleted) then {
+ profileNamespace setVariable ["FORGE_Phone_WorldClocks", _newClocks];
+ diag_log format ["[PHONE] Deleted world clock [ID: %1]. Remaining clocks: %2", _clockId, count _newClocks];
+ } else {
+ diag_log format ["[PHONE] World clock not found for deletion [ID: %1]", _clockId];
+ };
+ };
+ case "phone::get::alarms": {
+ private _alarms = profileNamespace getVariable ["FORGE_Phone_Alarms", []];
+
+ _control ctrlWebBrowserAction ["ExecJS", format ["loadAlarms(%1)", (toJSON _alarms)]];
+ };
+ case "phone::save::alarm": {
+ private _alarmId = _data get "id";
+ private _alarmTime = _data get "time";
+ private _alarmLabel = _data get "label";
+
+ private _alarms = profileNamespace getVariable ["FORGE_Phone_Alarms", []];
+ private _existingIndex = -1;
+ {
+ private _existingId = _x get "id";
+ if (_existingId isEqualTo _alarmId) then {
+ _existingIndex = _forEachIndex;
+ };
+ } forEach _alarms;
+
+ if (_existingIndex >= 0) then {
+ _alarms set [_existingIndex, _data];
+ diag_log format ["[PHONE] Updated alarm: %1 at %2 [ID: %3]", _alarmLabel, _alarmTime, _alarmId];
+ } else {
+ _alarms pushBack _data;
+ diag_log format ["[PHONE] Added new alarm: %1 at %2 [ID: %3]", _alarmLabel, _alarmTime, _alarmId];
+ };
+
+ profileNamespace setVariable ["FORGE_Phone_Alarms", _alarms];
+ diag_log format ["[PHONE] Saved alarms to profile. Total alarms: %1", count _alarms];
+ };
+ case "phone::delete::alarm": {
+ private _alarmId = _data get "id";
+
+ private _alarms = profileNamespace getVariable ["FORGE_Phone_Alarms", []];
+ private _newAlarms = [];
+ private _deleted = false;
+ {
+ private _existingId = _x get "id";
+ if (_existingId isEqualTo _alarmId) then {
+ _deleted = true;
+ } else {
+ _newAlarms pushBack _x;
+ };
+ } forEach _alarms;
+
+ if (_deleted) then {
+ profileNamespace setVariable ["FORGE_Phone_Alarms", _newAlarms];
+ diag_log format ["[PHONE] Deleted alarm [ID: %1]. Remaining alarms: %2", _alarmId, count _newAlarms];
+ } else {
+ diag_log format ["[PHONE] Alarm not found for deletion [ID: %1]", _alarmId];
+ };
+ };
+ case "phone::toggle::alarm": {
+ private _alarmId = _data get "id";
+
+ private _alarms = profileNamespace getVariable ["FORGE_Phone_Alarms", []];
+ {
+ private _existingId = _x get "id";
+ if (_existingId isEqualTo _alarmId) then {
+ private _currentEnabled = _x get "enabled";
+ _x set ["enabled", !_currentEnabled];
+ diag_log format ["[PHONE] Toggled alarm [ID: %1] to %2", _alarmId, !_currentEnabled];
+ };
+ } forEach _alarms;
+
+ profileNamespace setVariable ["FORGE_Phone_Alarms", _alarms];
+ };
+ default { hint format ["Unhandled phone event: %1", _event]; };
+};
+
+true;
diff --git a/arma/client/addons/phone/functions/fnc_initClass.sqf b/arma/client/addons/phone/functions/fnc_initClass.sqf
new file mode 100644
index 0000000..71bae1c
--- /dev/null
+++ b/arma/client/addons/phone/functions/fnc_initClass.sqf
@@ -0,0 +1,293 @@
+#include "..\script_component.hpp"
+
+#pragma hemtt ignore_variables ["_self"]
+
+/*
+ * Author: IDSolutions
+ * Initialize unified phone class
+ *
+ * Arguments:
+ * N/A
+ *
+ * Return Value:
+ * N/A
+ *
+ * Examples:
+ * [] call forge_client_phone_fnc_initClass
+ *
+ * Public: Yes
+ */
+
+// TODO: Perform comprehensive review and edit of phone class implementation
+// Then integrate this class to replace current phone handling logic
+// Key areas to address:
+// - Verify all phone data structures and methods
+// - Ensure proper data persistence
+// - Implement robust error handling
+// - Replace direct UI manipulation with class-based approach
+GVAR(PhoneClass) = createHashMapObject [[
+ ["#type", "IPhoneClass"],
+ ["#create", {
+ _self set ["uid", getPlayerUID player];
+ _self set ["notes", createHashMap];
+ _self set ["events", []];
+ _self set ["settings", createHashMap];
+ _self set ["isLoaded", false];
+ _self set ["lastSave", time];
+
+ // Initialize default settings
+ private _settings = createHashMap;
+ _settings set ["theme", "light"];
+ _settings set ["notifications", true];
+ _settings set ["sound", true];
+ _settings set ["vibration", true];
+ _self set ["settings", _settings];
+ }],
+ ["init", {
+ // Contacts/messages/emails are server-owned. Keep only local utility-app
+ // state in profileNamespace until those apps are migrated.
+ private _savedNotes = profileNamespace getVariable ["FORGE_Phone_Notes", createHashMap];
+ private _savedEvents = profileNamespace getVariable ["FORGE_Phone_Events", []];
+ private _savedSettings = profileNamespace getVariable ["FORGE_Phone_Settings", createHashMap];
+
+ _self set ["notes", _savedNotes];
+ _self set ["events", _savedEvents];
+
+ // Merge saved settings with defaults
+ private _defaultSettings = _self get "settings";
+ {
+ _defaultSettings set [_x, _y];
+ } forEach _savedSettings;
+
+ _self set ["settings", _defaultSettings];
+ _self set ["isLoaded", true];
+
+ systemChat format ["Phone loaded for %1", (name player)];
+ diag_log "[FORGE:Client:Phone] Phone Class Initialized!";
+ }],
+ ["_padString", {
+ params [["_number", 0, [0]], ["_length", 0, [0]]];
+
+ private _str = str _number;
+
+ while { (_str select [(_length - 1), 1]) == "" } do { _str = "0" + _str };
+ _str
+ }],
+ ["save", {
+ params [["_sync", false, [false]]];
+
+ // Save local-only phone app state to profile.
+ profileNamespace setVariable ["FORGE_Phone_Notes", _self get "notes"];
+ profileNamespace setVariable ["FORGE_Phone_Events", _self get "events"];
+ profileNamespace setVariable ["FORGE_Phone_Settings", _self get "settings"];
+
+ if (_sync) then { saveProfileNamespace; };
+
+ _self set ["lastSave", time];
+ }],
+ ["sync", {
+ params [["_data", createHashMap, [createHashMap]]];
+
+ if (_data isEqualTo createHashMap) exitWith { diag_log "[FORGE:Client:Phone] Empty data received for sync, skipping."; };
+ }],
+ ["get", {
+ params [["_key", "", [""]], ["_default", nil, [[], "", 0, false, createHashMap]]];
+
+ private _settings = _self get "settings";
+ _settings getOrDefault [_key, _default];
+ }],
+ ["addNote", {
+ params [["_data", createHashMap, [createHashMap]]];
+
+ if (_data isEqualTo createHashMap) exitWith { false };
+
+ private _noteId = _data get "id";
+ private _notes = _self get "notes";
+
+ _notes set [_noteId, _data];
+ _self call ["save", [true]];
+
+ diag_log format ["[FORGE:Client:Phone] Added note [ID: %1]", _noteId];
+ true
+ }],
+ ["updateNote", {
+ params [["_data", createHashMap, [createHashMap]]];
+
+ private _noteId = _data get "id";
+ if (isNil "_noteId" || _noteId == "") exitWith { false };
+
+ private _notes = _self get "notes";
+ if !(_noteId in _notes) exitWith { false };
+
+ _notes set [_noteId, _data];
+ _self set ["notes", _notes];
+ _self call ["save", [true]];
+
+ diag_log format ["[FORGE:Client:Phone] Updated note [ID: %1]", _noteId];
+ true
+ }],
+ ["deleteNote", {
+ params [["_noteId", "", [""]]];
+
+ if (_noteId == "") exitWith { false };
+
+ private _notes = _self get "notes";
+ if (!(_noteId in _notes)) exitWith { false };
+
+ _notes deleteAt _noteId;
+ _self set ["notes", _notes];
+ _self call ["save", [true]];
+
+ diag_log format ["[FORGE:Client:Phone] Deleted note [ID: %1]", _noteId];
+ true
+ }],
+ ["getNote", {
+ params [["_noteId", "", [""]], ["_default", nil]];
+
+ private _notes = _self get "notes";
+ _notes getOrDefault [_noteId, _default];
+ }],
+ ["getAllNotes", {
+ private _notes = _self get "notes";
+ private _notesArray = [];
+
+ {
+ _notesArray pushBack _y;
+ } forEach _notes;
+
+ _notesArray
+ }],
+ ["setSetting", {
+ params [["_key", "", [""]], ["_value", nil]];
+
+ if (_key == "") exitWith { false };
+
+ private _settings = _self get "settings";
+ _settings set [_key, _value];
+ _self set ["settings", _settings];
+ _self call ["save", [true]];
+
+ true
+ }],
+ ["getSetting", {
+ params [["_key", "", [""]], ["_default", nil]];
+
+ private _settings = _self get "settings";
+ _settings getOrDefault [_key, _default];
+ }],
+ ["getAllSettings", {
+ _self get "settings";
+ }],
+ ["addEvent", {
+ params [["_eventData", createHashMap, [createHashMap]]];
+
+ if (_eventData isEqualTo createHashMap) exitWith { false };
+
+ private _eventId = _eventData get "id";
+ if (isNil "_eventId" || _eventId == "") exitWith { false };
+
+ private _events = _self get "events";
+
+ // Check if event already exists
+ private _existingIndex = _events findIf {(_x get "id") isEqualTo _eventId};
+
+ if (_existingIndex >= 0) then {
+ // Update existing event
+ _events set [_existingIndex, _eventData];
+ diag_log format ["[FORGE:Client:Phone] Updated event [ID: %1]", _eventId];
+ } else {
+ // Add new event
+ _events pushBack _eventData;
+ diag_log format ["[FORGE:Client:Phone] Added event [ID: %1]", _eventId];
+ };
+
+ _self set ["events", _events];
+ _self call ["save", [true]];
+ true
+ }],
+ ["updateEvent", {
+ params [["_eventData", createHashMap, [createHashMap]]];
+
+ private _eventId = _eventData get "id";
+ if (isNil "_eventId" || _eventId == "") exitWith { false };
+
+ private _events = _self get "events";
+ private _existingIndex = _events findIf {(_x get "id") isEqualTo _eventId};
+
+ if (_existingIndex < 0) exitWith { false };
+
+ _events set [_existingIndex, _eventData];
+ _self set ["events", _events];
+ _self call ["save", [true]];
+
+ diag_log format ["[FORGE:Client:Phone] Updated event [ID: %1]", _eventId];
+ true
+ }],
+ ["deleteEvent", {
+ params [["_eventId", "", [""]]];
+
+ if (_eventId == "") exitWith { false };
+
+ private _events = _self get "events";
+ private _existingIndex = _events findIf {(_x get "id") isEqualTo _eventId};
+
+ if (_existingIndex < 0) exitWith { false };
+
+ _events deleteAt _existingIndex;
+ _self set ["events", _events];
+ _self call ["save", [true]];
+
+ diag_log format ["[FORGE:Client:Phone] Deleted event [ID: %1]", _eventId];
+ true
+ }],
+ ["getEvent", {
+ params [["_eventId", "", [""]], ["_default", nil]];
+
+ private _events = _self get "events";
+ private _event = _events select {(_x get "id") isEqualTo _eventId};
+
+ if (_event isNotEqualTo []) then {
+ _event select 0
+ } else {
+ _default
+ };
+ }],
+ ["getAllEvents", {
+ private _events = _self get "events";
+ _events
+ }],
+ ["getEventsByDate", {
+ params [["_date", "", [""]]];
+
+ private _events = _self get "events";
+ private _dateEvents = _events select {
+ private _eventStartTime = _x get "startTime";
+ if (isNil "_eventStartTime") then { false } else {
+ // Extract date from ISO string (YYYY-MM-DD)
+ private _eventDate = (_eventStartTime splitString "T") select 0;
+ _eventDate isEqualTo _date
+ };
+ };
+
+ _dateEvents
+ }],
+ ["clearAllEvents", {
+ _self set ["events", []];
+ _self call ["save", [true]];
+ diag_log "[FORGE:Client:Phone] Cleared all events";
+ true
+ }],
+ ["getEventsForToday", {
+ private _currentTime = systemTimeUTC;
+ private _todayDate = format ["%1-%2-%3",
+ _currentTime select 0,
+ _self call ["_padString", [(_currentTime select 1), 2]],
+ _self call ["_padString", [(_currentTime select 2), 2]]
+ ];
+
+ _self call ["getEventsByDate", [_todayDate]]
+ }]
+]];
+
+SETVAR(player,FORGE_PhoneClass,GVAR(PhoneClass));
+GVAR(PhoneClass)
diff --git a/arma/client/addons/phone/functions/fnc_openUI.sqf b/arma/client/addons/phone/functions/fnc_openUI.sqf
new file mode 100644
index 0000000..e52b08c
--- /dev/null
+++ b/arma/client/addons/phone/functions/fnc_openUI.sqf
@@ -0,0 +1,31 @@
+#include "..\script_component.hpp"
+
+/*
+ * Author: IDSolutions
+ * Open phone interface.
+ *
+ * Arguments:
+ * None
+ *
+ * Return Value:
+ * None
+ *
+ * Example:
+ * [] call forge_client_phone_fnc_openUI;
+ *
+ * Public: No
+ */
+
+private _display = (findDisplay 46) createDisplay "RscPhone";
+private _ctrl = (_display displayCtrl 1001);
+
+_ctrl ctrlAddEventHandler ["JSDialog", {
+ params ["_control", "_isConfirmDialog", "_message"];
+
+ [_control, _isConfirmDialog, _message] call FUNC(handleUIEvents);
+}];
+
+_ctrl ctrlWebBrowserAction ["LoadFile", QUOTE(PATHTOF(ui\_site\index.html))];
+// _ctrl ctrlWebBrowserAction ["OpenDevConsole"];
+
+true;
diff --git a/arma/client/addons/phone/initKeybinds.inc.sqf b/arma/client/addons/phone/initKeybinds.inc.sqf
new file mode 100644
index 0000000..3981da7
--- /dev/null
+++ b/arma/client/addons/phone/initKeybinds.inc.sqf
@@ -0,0 +1,8 @@
+#include "\forge\forge_client\addons\main\data\hpp\defineDIKCodes.hpp"
+
+[
+ _category, QGVAR(ForgePhone),
+ [LSTRING(phone), LSTRING(phoneTooltip)], {
+ [] call FUNC(openUI)
+ }, {}, [DIK_P, [false, false, false]]
+] call CFUNC(addKeybind);
diff --git a/arma/client/addons/phone/script_component.hpp b/arma/client/addons/phone/script_component.hpp
new file mode 100644
index 0000000..9a22aa9
--- /dev/null
+++ b/arma/client/addons/phone/script_component.hpp
@@ -0,0 +1,9 @@
+#define COMPONENT phone
+#define COMPONENT_BEAUTIFIED Phone
+#include "\forge\forge_client\addons\main\script_mod.hpp"
+
+// #define DEBUG_MODE_FULL
+// #define DISABLE_COMPILE_CACHE
+// #define ENABLE_PERFORMANCE_COUNTERS
+
+#include "\forge\forge_client\addons\main\script_macros.hpp"
diff --git a/arma/client/addons/phone/stringtable.xml b/arma/client/addons/phone/stringtable.xml
new file mode 100644
index 0000000..7dbec09
--- /dev/null
+++ b/arma/client/addons/phone/stringtable.xml
@@ -0,0 +1,14 @@
+
+
+
+
+ Phone
+
+
+ Phone
+
+
+ Open your phone
+
+
+
diff --git a/arma/client/addons/phone/ui/RscCommon.hpp b/arma/client/addons/phone/ui/RscCommon.hpp
new file mode 100644
index 0000000..f50122c
--- /dev/null
+++ b/arma/client/addons/phone/ui/RscCommon.hpp
@@ -0,0 +1,265 @@
+// Control types
+#define CT_STATIC 0
+#define CT_BUTTON 1
+#define CT_EDIT 2
+#define CT_SLIDER 3
+#define CT_COMBO 4
+#define CT_LISTBOX 5
+#define CT_TOOLBOX 6
+#define CT_CHECKBOXES 7
+#define CT_PROGRESS 8
+#define CT_HTML 9
+#define CT_STATIC_SKEW 10
+#define CT_ACTIVETEXT 11
+#define CT_TREE 12
+#define CT_STRUCTURED_TEXT 13
+#define CT_CONTEXT_MENU 14
+#define CT_CONTROLS_GROUP 15
+#define CT_SHORTCUTBUTTON 16
+#define CT_HITZONES 17
+#define CT_XKEYDESC 40
+#define CT_XBUTTON 41
+#define CT_XLISTBOX 42
+#define CT_XSLIDER 43
+#define CT_XCOMBO 44
+#define CT_ANIMATED_TEXTURE 45
+#define CT_OBJECT 80
+#define CT_OBJECT_ZOOM 81
+#define CT_OBJECT_CONTAINER 82
+#define CT_OBJECT_CONT_ANIM 83
+#define CT_LINEBREAK 98
+#define CT_USER 99
+#define CT_MAP 100
+#define CT_MAP_MAIN 101
+#define CT_LISTNBOX 102
+#define CT_ITEMSLOT 103
+#define CT_CHECKBOX 77
+
+// Static styles
+#define ST_POS 0x0F
+#define ST_HPOS 0x03
+#define ST_VPOS 0x0C
+#define ST_LEFT 0x00
+#define ST_RIGHT 0x01
+#define ST_CENTER 0x02
+#define ST_DOWN 0x04
+#define ST_UP 0x08
+#define ST_VCENTER 0x0C
+
+#define ST_TYPE 0xF0
+#define ST_SINGLE 0x00
+#define ST_MULTI 0x10
+#define ST_TITLE_BAR 0x20
+#define ST_PICTURE 0x30
+#define ST_FRAME 0x40
+#define ST_BACKGROUND 0x50
+#define ST_GROUP_BOX 0x60
+#define ST_GROUP_BOX2 0x70
+#define ST_HUD_BACKGROUND 0x80
+#define ST_TILE_PICTURE 0x90
+#define ST_WITH_RECT 0xA0
+#define ST_LINE 0xB0
+#define ST_UPPERCASE 0xC0
+#define ST_LOWERCASE 0xD0
+
+#define ST_SHADOW 0x100
+#define ST_NO_RECT 0x200
+#define ST_KEEP_ASPECT_RATIO 0x800
+
+// Slider styles
+#define SL_DIR 0x400
+#define SL_VERT 0
+#define SL_HORZ 0x400
+
+#define SL_TEXTURES 0x10
+
+// progress bar
+#define ST_VERTICAL 0x01
+#define ST_HORIZONTAL 0
+
+// Listbox styles
+#define LB_TEXTURES 0x10
+#define LB_MULTI 0x20
+
+// Tree styles
+#define TR_SHOWROOT 1
+#define TR_AUTOCOLLAPSE 2
+
+// Default text sizes
+#define GUI_TEXT_SIZE_SMALL (GUI_GRID_H * 0.8)
+#define GUI_TEXT_SIZE_MEDIUM (GUI_GRID_H * 1)
+#define GUI_TEXT_SIZE_LARGE (GUI_GRID_H * 1.2)
+
+// Pixel grid
+#define pixelScale 0.50
+#define GRID_W (pixelW * pixelGrid * pixelScale)
+#define GRID_H (pixelH * pixelGrid * pixelScale)
+
+class ScrollBar;
+class RscObject;
+class RscText;
+class RscTextSmall;
+class RscTitle;
+class RscProgress;
+class RscProgressNotFreeze;
+class RscPicture;
+class RscLadderPicture;
+class RscPictureKeepAspect;
+class RscHTML;
+class RscButton;
+class RscShortcutButton;
+class RscButtonSmall;
+class RscEdit;
+class RscCombo;
+class RscListBox;
+class RscListNBox;
+class RscXListBox;
+class RscTree;
+class RscSlider;
+class RscSliderH;
+class RscXSliderH;
+class RscActiveText;
+class RscStructuredText;
+class RscControlsGroup;
+class RscToolbox;
+class RscMapControl;
+class RscCheckBox;
+class RscFrame;
+class ctrlDefault;
+class ctrlControlsGroup;
+class ctrlDefaultText;
+class ctrlDefaultButton;
+class RscBackgroundStripeTop;
+class RscBackgroundStripeBottom;
+class RscIGText;
+class RscIGProgress;
+class RscListBoxKeys;
+class RscControlsGroupNoScrollbars;
+class RscControlsGroupNoHScrollbars;
+class RscControlsGroupNoVScrollbars;
+class RscLine;
+class RscActivePicture;
+class RscButtonTextOnly;
+class RscShortcutButtonMain;
+class RscButtonEditor;
+class RscIGUIShortcutButton;
+class RscGearShortcutButton;
+class RscButtonMenu;
+class RscButtonMenuOK;
+class RscButtonMenuCancel;
+class RscButtonMenuSteam;
+class RscLoadingText;
+class RscIGUIListBox;
+class RscIGUIListNBox;
+class RscBackground;
+class RscBackgroundGUI;
+class RscBackgroundGUILeft;
+class RscBackgroundGUIRight;
+class RscBackgroundGUIBottom;
+class RscBackgroundGUITop;
+class RscBackgroundGUIDark;
+class RscBackgroundLogo;
+class RscMapControlEmpty;
+class RscVignette;
+class CA_Mainback;
+class CA_Back;
+class CA_Title_Back;
+class CA_Black_Back;
+class CA_Title;
+class CA_Logo;
+class CA_Logo_Small;
+class CA_RscButton;
+class CA_RscButton_dialog;
+class CA_Ok;
+class CA_Ok_image;
+class CA_Ok_image2;
+class CA_Ok_text;
+class ctrlCheckbox;
+class ctrlCheckboxBaseline;
+class ctrlStatic;
+class ctrlControlsGroupNoScrollbars;
+class ctrlStructuredText;
+class RscTextMulti;
+class RscTreeSearch;
+class RscVideo;
+class RscVideoKeepAspect;
+class RscActivePictureKeepAspect;
+class RscEditMulti;
+class RscMapSignalBackground;
+class RscMapSignalPicture;
+class RscMapSignalText;
+class RscColorPicker;
+class RscInterlacingScreen;
+class RscFeedback;
+class RscTrafficLight;
+class RscButtonSearch;
+class RscIGUIText;
+class RscOpticsText;
+class RscOpticsValue;
+class RscIGUIValue;
+class RscButtonMenuMain;
+class RscButtonTestCentered;
+class RscDisplaySingleMission_ChallengeOverviewGroup;
+class RscDisplayDebriefing_RscTextMultiline;
+class RscDisplayDebriefing_ListGroup;
+class RscButtonArsenal;
+class RscTextNoShadow;
+class RscButtonNoColor;
+class RscToolboxButton;
+class ctrlStaticPicture;
+class ctrlStaticPictureKeepAspect;
+class ctrlStaticPictureTile;
+class ctrlStaticFrame;
+class ctrlStaticLine;
+class ctrlStaticMulti;
+class ctrlStaticBackground;
+class ctrlStaticOverlay;
+class ctrlStaticTitle;
+class ctrlStaticFooter;
+class ctrlStaticBackgroundDisable;
+class ctrlStaticBackgroundDisableTiles;
+class ctrlButton;
+class ctrlButtonPicture;
+class ctrlButtonPictureKeepAspect;
+class ctrlButtonOK;
+class ctrlButtonCancel;
+class ctrlButtonClose;
+class ctrlButtonToolbar;
+class ctrlButtonSearch;
+class ctrlButtonExpandAll;
+class ctrlButtonCollapseAll;
+class ctrlButtonFilter;
+class ctrlEdit;
+class ctrlEditMulti;
+class ctrlSliderV;
+class ctrlSliderH;
+class ctrlCombo;
+class ctrlComboToolbar;
+class ctrlListbox;
+class ctrlToolbox;
+class ctrlToolboxPicture;
+class ctrlToolboxPictureKeepAspect;
+class ctrlCheckboxes;
+class ctrlCheckboxesCheckbox;
+class ctrlProgress;
+class ctrlHTML;
+class ctrlActiveText;
+class ctrlActivePicture;
+class ctrlActivePictureKeepAspect;
+class ctrlTree;
+class ctrlControlsGroupNoHScrollbars;
+class ctrlControlsGroupNoVScrollbars;
+class ctrlShortcutButton;
+class ctrlShortcutButtonOK;
+class ctrlShortcutButtonCancel;
+class ctrlShortcutButtonSteam;
+class ctrlXListbox;
+class ctrlXSliderV;
+class ctrlXSliderH;
+class ctrlMenu;
+class ctrlMenuStrip;
+class ctrlMap;
+class ctrlMapEmpty;
+class ctrlMapMain;
+class ctrlListNBox;
+class ctrlCheckboxToolbar;
diff --git a/arma/client/addons/phone/ui/RscPhone.hpp b/arma/client/addons/phone/ui/RscPhone.hpp
new file mode 100644
index 0000000..8c425f2
--- /dev/null
+++ b/arma/client/addons/phone/ui/RscPhone.hpp
@@ -0,0 +1,22 @@
+class RscPhone {
+ idd = 1000;
+ movingEnable = 1;
+ enableSimulation = 1;
+ duration = 1e011;
+ fadeIn = 0;
+ fadeOut = 0;
+ onLoad = "uiNamespace setVariable ['RscPhone', _this select 0]";
+
+ class controlsBackground {};
+ class controls {
+ class Background: RscText {
+ type = 106;
+ idc = 1001;
+ x = "safezoneX + (safezoneW * 0.4125)";
+ y = "safezoneY + (safezoneH * 0.1)";
+ w = "safezoneW * 1";
+ h = "safezoneH * 1";
+ colorBackground[] = {0, 0, 0, 0};
+ };
+ };
+};
diff --git a/arma/client/addons/phone/ui/_site/README.md b/arma/client/addons/phone/ui/_site/README.md
new file mode 100644
index 0000000..a90b767
--- /dev/null
+++ b/arma/client/addons/phone/ui/_site/README.md
@@ -0,0 +1,156 @@
+# Phone UI Framework
+
+A lightweight, component-based framework for building phone-like user interfaces in the browser. This framework provides a React-like development experience without external dependencies, making it perfect for creating mobile-first web applications.
+
+## Features
+
+- Component-based architecture (React-like API)
+- Virtual DOM-like rendering system
+- Built-in global and local state management
+- Modular, maintainable CSS structure
+- Mobile-first, accessible design (ARIA roles/labels)
+- No external dependencies
+- Easy production bundling (JS & CSS)
+
+## Getting Started
+
+1. Clone the repository
+2. **On Windows, run the provided script to build and start the local server:**
+ ```powershell
+ ./start.ps1
+ ```
+ This will automatically build the JS and CSS bundles and open the app in your browser at [http://localhost:8000](http://localhost:8000).
+
+3. **On Linux/macOS, run the provided shell script:**
+ ```sh
+ chmod +x start.sh
+ ./start.sh
+ ```
+ This will automatically build the JS and CSS bundles and open the app in your browser at [http://localhost:8000](http://localhost:8000).
+
+4. If you prefer, you can run the build manually with `node tools/concat-all.js` and start a local server (e.g., `python3 -m http.server`).
+
+> **Note:** The app will not work unless you run the build script. Always re-run the build script if you add, remove, or change any JS or CSS files.
+
+## Project Structure
+
+```
+โโโ index.html # Main entry point
+โโโ dist/ # Production bundles (auto-generated)
+โ โโโ app.bundle.js
+โ โโโ app.bundle.css
+โโโ styles/ # CSS files
+โ โโโ base.css
+โ โโโ main.css
+โ โโโ components/ # Component-specific styles
+โโโ js/ # JavaScript files
+โ โโโ core/ # Core framework (Component, StateManager)
+โ โโโ components/ # Shared UI components
+โ โโโ apps/ # App modules (phone, messages, contacts, settings)
+โ โโโ utils/ # Utility functions (scriptLoader, helpers)
+โ โโโ app.js # Main app integration/root
+โ โโโ main.js # App initialization
+โโโ tools/ # Build and utility scripts
+โ โโโ concat-js.js
+โ โโโ concat-css.js
+โ โโโ concat-all.js
+โโโ start.ps1 # Windows script to build and start local server
+โโโ start.sh # Linux/macOS script to build and start local server
+โโโ images/ # Image assets
+```
+
+## App Structure
+
+- **Main App (`App` class in `js/app.js`)**: Handles app switching, global modals, and integration.
+- **Apps (`js/apps/`)**: Each app (Phone, Messages, Contacts, Settings) has its own entry point (`index.js`) and components.
+- **Components (`js/components/` and app subfolders)**: Reusable UI elements (NavigationBar, Modal, StatusBar, etc.).
+- **State Management (`js/core/StateManager.js`)**: Global state via `globalState`, plus local state in components.
+- **Utilities (`js/utils/`)**: Script loader, helpers, etc.
+
+## Creating Components
+
+Components are created by extending the base `Component` class:
+
+```javascript
+class MyComponent extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { /* ... */ };
+ }
+ render() {
+ return this.createElement('div', { className: 'my-component' }, 'Hello World');
+ }
+}
+```
+
+### Component Lifecycle
+- `constructor(props)`: Initialize component
+- `render()`: Define component structure
+- `componentDidMount()`: Called after mount
+- `componentWillUnmount()`: Called before unmount
+- `onStateChange(prevState, newState)`: On state change
+
+### State Management
+- Local: `this.setState({ ... })`
+- Global: `globalState.setState({ ... })`, `globalState.subscribe(cb)`
+
+## Creating Elements
+
+Use `createElement` to create DOM elements:
+
+```javascript
+this.createElement('div', { className: 'container', onClick: ... }, 'Content');
+```
+
+## Styling
+
+- Base styles: `base.css`, `main.css`
+- Component styles: `styles/components/`
+- For all environments, use the bundled `dist/app.bundle.css`
+
+## Available Components
+
+- `StatusBar`, `NavigationBar`, `Modal`, `HomeScreen`, `HomeIndicator`, `Header`, `SearchBar`
+- App-specific: `ContactList`, `ContactItem`, `AddContactForm`, `MessagesList`, `MessageItem`, `ConversationView`, `Dialpad`, `Settings`
+
+## Scripts
+
+- `tools/concat-js.js`: Bundles all JS files into `dist/app.bundle.js`
+- `tools/concat-css.js`: Bundles all CSS files into `dist/app.bundle.css`
+- `tools/concat-all.js`: Bundles both JS and CSS (**required for all environments**)
+- `start.ps1`: Builds and starts a local server on Windows
+- `start.sh`: Builds and starts a local server on Linux/macOS
+
+## How to Add a New App
+
+1. Create a new folder in `js/apps/yourapp/` with an `index.js` and any components.
+2. Add your app's entry point to the bundler scripts and (if needed) to the app switch logic in `js/app.js`.
+3. Add styles in `styles/components/yourapp.css` and include in the CSS bundle list.
+4. **Re-run the build script after any changes.**
+
+## Best Practices
+
+1. Keep components small and focused
+2. Use state management for global data
+3. Follow the component lifecycle
+4. Use modular CSS for styling
+5. Handle cleanup in `componentWillUnmount`
+6. Use ARIA roles/labels for accessibility
+
+## Development & Production
+
+- **Always run the build script (`node tools/concat-all.js`, `./start.ps1`, or `./start.sh`) before starting or deploying the app.**
+- The app will not work unless all JS and CSS are bundled.
+- If you encounter issues, re-run the build script to ensure all files are up to date.
+
+## Contributing
+
+1. Fork the repository
+2. Create your feature branch
+3. Commit your changes
+4. Push to the branch
+5. Create a Pull Request
+
+## License
+
+This project is licensed under the MIT License.
\ No newline at end of file
diff --git a/arma/client/addons/phone/ui/_site/dist/app.bundle.css b/arma/client/addons/phone/ui/_site/dist/app.bundle.css
new file mode 100644
index 0000000..e5ac873
--- /dev/null
+++ b/arma/client/addons/phone/ui/_site/dist/app.bundle.css
@@ -0,0 +1,2986 @@
+
+/* ---- ../styles/base.css ---- */
+/* Base styles and CSS reset */
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+:root {
+ /* Light theme (default) */
+ --bg-primary: #ffffff;
+ --bg-secondary: #f8f9fa;
+ --text-primary: #000000;
+ --text-secondary: #6c757d;
+ --text-tertiary: #1c1c1e;
+ --border-color: #e9ecef;
+ --accent-color: #007aff;
+ --status-bar-bg: rgba(248, 249, 250, 0.95);
+ --nav-bg: #f8f9fa;
+ --message-bubble-user: #007aff;
+ --message-bubble-contact: #e9ecef;
+ --message-text-user: #ffffff;
+ --message-text-contact: #000000;
+ --input-bg: #ffffff;
+ --input-border: #ddd;
+ --icon-path: 'images/light/';
+}
+
+[data-theme="dark"] {
+ --bg-primary: #000000;
+ --bg-secondary: #1c1c1e;
+ --text-primary: #ffffff;
+ --text-secondary: #8e8e93;
+ --border-color: #38383a;
+ --accent-color: #0a84ff;
+ --status-bar-bg: rgba(28, 28, 30, 0.95);
+ --nav-bg: #1c1c1e;
+ --message-bubble-user: #0a84ff;
+ --message-bubble-contact: #2c2c2e;
+ --message-text-user: #ffffff;
+ --message-text-contact: #ffffff;
+ --input-bg: #2c2c2e;
+ --input-border: #38383a;
+ --icon-path: 'images/dark/';
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
+ background: transparent;
+ min-height: 100vh;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 20px;
+ color: var(--text-primary);
+}
+
+html, body {
+ overflow: hidden !important;
+ -ms-overflow-style: none !important;
+ scrollbar-width: none !important;
+}
+
+html::-webkit-scrollbar,
+body::-webkit-scrollbar {
+ width: 0px !important;
+ height: 0px !important;
+ display: none !important;
+}
+
+/* ---- ../styles/main.css ---- */
+/* Utility classes */
+.hidden {
+ display: none !important;
+}
+
+/* Animations */
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes slideUp {
+ from {
+ transform: translateY(20px);
+ opacity: 0;
+ }
+
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
+
+.fade-in {
+ animation: fadeIn 0.3s ease forwards;
+}
+
+.slide-up {
+ animation: slideUp 0.3s ease forwards;
+}
+
+/* Responsive Design */
+@media (max-width: 480px) {
+ .phone-container {
+ width: 100%;
+ height: 100vh;
+ border-radius: 0;
+ padding: 0;
+ }
+
+ .phone-screen {
+ border-radius: 0;
+ }
+
+ body {
+ padding: 0;
+ }
+}
+
+/* Remove unused styles */
+.home-button-container,
+.home-button,
+.nav-home-button {
+ display: none;
+}
+
+/* Search Bar */
+.search-bar {
+ input {
+ &::placeholder {
+ color: var(--text-secondary);
+ }
+ }
+}
+
+/* ---- ../styles/components/layout.css ---- */
+/* App Container */
+.app-container {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ margin-bottom: 25px;
+ scrollbar-width: none !important;
+}
+
+/* Content Areas */
+.content {
+ flex: 1;
+ overflow: hidden;
+ padding: 10px;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ scrollbar-width: none !important;
+}
+
+/* ---- ../styles/components/phone.css ---- */
+/* Phone */
+.phone-container {
+ width: 375px;
+ height: 720px;
+ background: linear-gradient(145deg, #0a0a0a 0%, #1d1d1d 25%, #232323 50%, #161616 75%, #0f0f0f 100%);
+ border: 2px solid #a8a8a8;
+ border-radius: 40px;
+ padding: 8px;
+ position: relative;
+
+ /* Volume Up Button */
+ &::before {
+ content: '';
+ position: absolute;
+ top: 120px;
+ right: -4px;
+ width: 3px;
+ height: 30px;
+ background: linear-gradient(145deg, #0a0a0a 0%, #1d1d1d 25%, #232323 50%, #161616 75%, #0f0f0f 100%);
+ border-radius: 2px;
+ box-shadow:
+ inset 0 1px 1px rgba(255, 255, 255, 0.1),
+ inset 0 -1px 1px rgba(0, 0, 0, 0.2);
+ z-index: 2;
+ }
+
+ /* Volume Down Button */
+ &::after {
+ content: '';
+ position: absolute;
+ top: 160px;
+ right: -4px;
+ width: 3px;
+ height: 30px;
+ background: linear-gradient(145deg, #0a0a0a 0%, #1d1d1d 25%, #232323 50%, #161616 75%, #0f0f0f 100%);
+ border-radius: 2px;
+ box-shadow:
+ inset 0 1px 1px rgba(255, 255, 255, 0.1),
+ inset 0 -1px 1px rgba(0, 0, 0, 0.2);
+ z-index: 2;
+ }
+
+ /* Power Button */
+ .power-button {
+ position: absolute;
+ top: 200px;
+ right: -4px;
+ width: 3px;
+ height: 40px;
+ background: linear-gradient(145deg, #0a0a0a 0%, #1d1d1d 25%, #232323 50%, #161616 75%, #0f0f0f 100%);
+ border-radius: 2px;
+ box-shadow:
+ inset 0 1px 1px rgba(255, 255, 255, 0.1),
+ inset 0 -1px 1px rgba(0, 0, 0, 0.2);
+ z-index: 2;
+ }
+
+ /* Mute Switch */
+ .mute-switch {
+ position: absolute;
+ top: 100px;
+ left: -4px;
+ width: 3px;
+ height: 20px;
+ background: linear-gradient(145deg, #0a0a0a 0%, #1d1d1d 25%, #232323 50%, #161616 75%, #0f0f0f 100%);
+ border-radius: 2px;
+ box-shadow:
+ inset 0 1px 1px rgba(255, 255, 255, 0.1),
+ inset 0 -1px 1px rgba(0, 0, 0, 0.2);
+ z-index: 2;
+ }
+
+ /* Action Button */
+ .action-button {
+ position: absolute;
+ top: 140px;
+ left: -4px;
+ width: 3px;
+ height: 20px;
+ background: linear-gradient(145deg, #0a0a0a 0%, #1d1d1d 25%, #232323 50%, #161616 75%, #0f0f0f 100%);
+ border-radius: 2px;
+ box-shadow:
+ inset 0 1px 1px rgba(255, 255, 255, 0.1),
+ inset 0 -1px 1px rgba(0, 0, 0, 0.2);
+ z-index: 2;
+ }
+}
+
+.phone-screen {
+ width: 100%;
+ height: 100%;
+ background: var(--bg-primary);
+ border-radius: 32px;
+ overflow: hidden;
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ z-index: 1;
+ color: var(--text-primary);
+ box-shadow: 0 -2px 8px 0 rgba(0, 0, 0, 0.7), 0 2px 8px 0 rgba(0, 0, 0, 0.05);
+ border-top: 3px solid #0f0f0f;
+
+ &::after {
+ content: '';
+ position: absolute;
+ top: 8px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 142px;
+ height: 32px;
+ background: #000000;
+ border-radius: 16px;
+ z-index: 1001;
+ display: none;
+ }
+
+ &.dynamic-island::after {
+ display: block;
+ }
+}
+
+.dynamic-island-content {
+ position: absolute;
+ top: 8px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 142px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 12px;
+ z-index: 1002;
+
+ .speaker {
+ width: 64px;
+ height: 6px;
+ background: #333333;
+ border-radius: 3px;
+ position: relative;
+
+ &::before {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 52px;
+ height: 2px;
+ background: #000000;
+ border-radius: 1px;
+ }
+ }
+
+ .camera {
+ width: 12px;
+ height: 12px;
+ background: #333333;
+ border-radius: 50%;
+ border: 1px solid #000000;
+ position: relative;
+
+ &::before {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 6px;
+ height: 6px;
+ background: #000000;
+ border-radius: 50%;
+ }
+ }
+}
+
+/* Home Indicator */
+.home-indicator-container {
+ position: absolute;
+ bottom: 4px;
+ left: 50%;
+ transform: translateX(-50%);
+ padding: 8px 16px;
+ cursor: pointer;
+ z-index: 100;
+ background: var(--status-bar-bg);
+ border-radius: 12px;
+ backdrop-filter: blur(10px);
+ border: 1px solid var(--border-color);
+
+ &:hover .home-indicator {
+ background: var(--text-primary);
+ opacity: 0.5;
+ transform: scaleY(1.2);
+ }
+
+ .home-indicator {
+ width: 134px;
+ height: 5px;
+ background: var(--text-primary);
+ opacity: 0.3;
+ border-radius: 3px;
+ transition: all 0.2s ease;
+ }
+}
+
+/* ---- ../styles/components/buttons.css ---- */
+/* Button Styles */
+.button {
+ background: #007aff;
+ color: white;
+ border: none;
+ padding: 12px 24px;
+ border-radius: 8px;
+ font-size: 16px;
+ cursor: pointer;
+ transition: background-color 0.2s;
+ margin: 10px 5px;
+
+ &:hover {
+ background: #0056cc;
+ }
+
+ &.secondary {
+ background: #6c757d;
+
+ &:hover {
+ background: #545b62;
+ }
+ }
+}
+
+/* ---- ../styles/components/modal.css ---- */
+/* Modal */
+.modal-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+
+ .modal {
+ background: var(--bg-primary);
+ border-radius: 12px;
+ padding: 24px;
+ margin: 20px;
+ max-width: 300px;
+ width: 100%;
+ border: 1px solid var(--border-color);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
+
+ h2 {
+ margin-bottom: 16px;
+ font-size: 18px;
+ color: var(--text-primary);
+ }
+
+ p {
+ margin-bottom: 20px;
+ color: var(--text-secondary);
+ }
+
+ .modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+
+ button {
+ width: 100%;
+ }
+
+ /* Style delete buttons differently */
+ button[aria-label="Delete"] {
+ background: #ff4444;
+ border-color: #ff4444;
+ }
+
+ button[aria-label="Delete"]:hover {
+ background: #ff3333;
+ border-color: #ff3333;
+ }
+
+ button[aria-label="Delete"]:active {
+ background: #ff2222;
+ border-color: #ff2222;
+ }
+ }
+ }
+}
+
+/* ---- ../styles/components/nav-bar.css ---- */
+/* Navigation Bar */
+.navigation-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 20px;
+ background: var(--nav-bg);
+ border-bottom: 1px solid var(--border-color);
+ min-height: 50px;
+ position: relative;
+ z-index: 1;
+}
+
+.navigation-bar .nav-back-button {
+ background: none;
+ border: none;
+ color: var(--accent-color);
+ font-size: 16px;
+ cursor: pointer;
+ padding: 8px 12px;
+ border-radius: 6px;
+ transition: background-color 0.2s;
+}
+
+.navigation-bar .nav-back-button:hover {
+ background: rgba(0, 122, 255, 0.1);
+}
+
+.navigation-bar .nav-title {
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin: 0;
+ flex: 1;
+ text-align: center;
+ position: absolute;
+ left: 50%;
+ transform: translateX(-50%);
+}
+
+.navigation-bar .nav-spacer {
+ width: 64px;
+}
+
+.navigation-bar .nav-button {
+ background: none;
+ border: none;
+ color: var(--accent-color);
+ font-size: 24px;
+ padding: 8px 12px;
+ cursor: pointer;
+ transition: opacity 0.2s;
+ position: relative;
+ z-index: 2;
+}
+
+.navigation-bar .nav-button:hover {
+ opacity: 0.8;
+}
+
+.navigation-bar .nav-button:active {
+ opacity: 0.6;
+}
+
+.navigation-bar .nav-button.add-button {
+ font-size: 28px;
+ font-weight: 300;
+ line-height: 1;
+ color: var(--accent-color);
+}
+
+/* ---- ../styles/components/status-bar.css ---- */
+/* Status Bar */
+.status-bar {
+ height: 44px;
+ background: var(--status-bar-bg);
+ backdrop-filter: blur(10px);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0 20px;
+ font-size: 14px;
+ font-weight: 600;
+ flex-shrink: 0;
+ z-index: 10;
+ position: relative;
+ color: var(--text-primary);
+
+ .status-left {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex: 1;
+ }
+
+ .status-center {
+ position: absolute;
+ left: 50%;
+ transform: translateX(-50%);
+ font-weight: 600;
+ font-size: 14px;
+ }
+
+ .status-right {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ flex: 1;
+ justify-content: flex-end;
+
+ .status-indicators {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+
+ .signal-bars {
+ display: flex;
+ align-items: flex-end;
+ gap: 1px;
+ height: 12px;
+
+ .bar {
+ background: var(--text-primary);
+ border-radius: 1px;
+ width: 3px;
+ margin-right: 1px;
+
+ &:nth-child(1) {
+ height: 3px;
+ }
+
+ &:nth-child(2) {
+ height: 5px;
+ }
+
+ &:nth-child(3) {
+ height: 7px;
+ }
+
+ &:nth-child(4) {
+ height: 9px;
+ }
+ }
+ }
+
+ .network-battery {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 12px;
+ color: var(--text-primary);
+
+ .battery-icon {
+ position: relative;
+ display: inline-block;
+ width: 20px;
+ height: 10px;
+ border: 1.5px solid var(--text-primary);
+ border-radius: 3px;
+ box-sizing: border-box;
+
+ &::before {
+ content: "";
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ right: 2px;
+ bottom: 2px;
+ border-radius: 1px;
+ background: var(--text-primary);
+ }
+
+ &::after {
+ content: "";
+ position: absolute;
+ top: 2px;
+ right: -4px;
+ width: 2px;
+ height: 4px;
+ border-radius: 0 1px 1px 0;
+ background: var(--text-primary);
+ }
+ }
+ }
+ }
+ }
+}
+
+
+/* ---- ../styles/components/home.css ---- */
+/* Home Screen */
+.home-screen {
+ flex: 1;
+ background-size: cover;
+ background-position: center;
+ padding: 60px 0 20px;
+ display: flex;
+ flex-direction: column;
+ position: relative;
+}
+
+[data-theme="dark"] .home-screen {
+ background-size: cover;
+ background-position: center;
+}
+
+.home-header {
+ text-align: center;
+ margin-bottom: 40px;
+ padding: 0 20px;
+
+ h1 {
+ color: rgba(255, 255, 255, 0.8);
+ font-size: 24px;
+ font-weight: 500;
+ }
+}
+
+.app-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 65px);
+ gap: 25px;
+ justify-content: center;
+ flex: 1;
+ align-content: start;
+ padding: 0;
+ width: 100%;
+}
+
+.dock {
+ position: absolute;
+ bottom: 32px;
+ left: 50%;
+ transform: translateX(-50%);
+ background: rgba(255, 255, 255, 0.15);
+ backdrop-filter: blur(20px);
+ -webkit-backdrop-filter: blur(20px);
+ border-radius: 20px;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
+ padding: 8px 8px 4px 8px;
+ display: flex;
+ align-items: center;
+ gap: 20px;
+}
+
+[data-theme="dark"] .dock {
+ background: rgba(0, 0, 0, 0.25);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
+}
+
+.app-icon {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+ cursor: pointer;
+ transition: transform 0.2s ease;
+
+ &:hover {
+ transform: scale(1.05);
+ }
+
+ .app-icon-symbol {
+ width: 64px;
+ height: 64px;
+ border-radius: 16px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 28px;
+ position: relative;
+ overflow: hidden;
+ padding: 0;
+
+ &[style*="background"] {
+ background: var(--app-color);
+ }
+
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ border-radius: 16px;
+ }
+
+ &::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ }
+ }
+
+ .app-title {
+ color: var(--text-primary);
+ font-size: 12px;
+ font-weight: 400;
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
+ text-align: center;
+ width: 65px;
+ }
+}
+
+/* ---- ../styles/components/contacts.css ---- */
+/* Contacts App */
+.contact-list {
+ list-style: none;
+
+ .contact-item {
+ display: flex;
+ align-items: center;
+ padding: 15px 0;
+ border-bottom: 1px solid #e9ecef;
+ cursor: pointer;
+ transition: background-color 0.2s;
+
+ &:hover {
+ background-color: #f8f9fa;
+ color: var(--text-tertiary);
+ }
+
+ .contact-avatar {
+ width: 50px;
+ height: 50px;
+ border-radius: 25px;
+ background: linear-gradient(45deg, #ff6b6b, #4ecdc4);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ font-weight: bold;
+ margin-right: 15px;
+ }
+
+ .contact-info {
+ h3 {
+ font-size: 16px;
+ margin-bottom: 4px;
+ }
+
+ p {
+ font-size: 14px;
+ color: #6c757d;
+ }
+ }
+ }
+}
+
+/* Add Contact Form */
+.add-contact-form {
+ background: var(--bg-primary);
+ margin-bottom: 10px;
+
+ h3 {
+ color: var(--text-primary);
+ font-size: 18px;
+ font-weight: 600;
+ margin-bottom: 15px;
+ }
+
+ input {
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ border: 1px solid var(--border-color);
+ transition: border-color 0.2s;
+ width: 100%;
+ margin-bottom: 15px;
+ padding: 10px;
+ border-radius: 4px;
+
+ &:focus {
+ outline: none;
+ }
+
+ &::placeholder {
+ color: var(--text-secondary);
+ }
+ }
+
+ button {
+ background: var(--accent-color);
+ color: white;
+ border: none;
+ padding: 12px;
+ border-radius: 8px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: opacity 0.2s;
+ margin: 0 !important;
+ width: 100% !important;
+
+ &:hover {
+ opacity: 0.9;
+ }
+
+ &:active {
+ opacity: 0.8;
+ }
+ }
+}
+
+/* ---- ../styles/components/dialpad.css ---- */
+/* Dialpad */
+.phone-dialpad {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ padding: 20px;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+
+ &.call-active {
+ justify-content: center;
+ text-align: center;
+ }
+}
+
+.phone-display {
+ text-align: center;
+ padding: 40px 20px;
+ margin-bottom: 20px;
+}
+
+.phone-number {
+ font-size: 32px;
+ font-weight: 300;
+ color: var(--text-primary);
+ min-height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.dialpad {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 12px;
+ margin-bottom: 30px;
+ max-width: 300px;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.dialpad-btn {
+ width: 75px;
+ height: 75px;
+ border-radius: 50%;
+ border: none;
+ background: var(--bg-secondary);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ position: relative;
+ overflow: hidden;
+
+ &:hover {
+ background: var(--border-color);
+ }
+
+ &:active {
+ transform: scale(0.95);
+ background: var(--border-color);
+ }
+
+ .number {
+ font-size: 32px;
+ font-weight: 400;
+ color: var(--text-primary);
+ line-height: 1;
+ margin-bottom: 2px;
+ }
+
+ .letters {
+ font-size: 10px;
+ color: var(--text-secondary);
+ font-weight: 500;
+ margin-top: 2px;
+ letter-spacing: 1px;
+ }
+
+ &::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: linear-gradient(135deg,
+ rgba(255, 255, 255, 0.2) 0%,
+ rgba(255, 255, 255, 0) 50%,
+ rgba(0, 0, 0, 0.05) 100%);
+ }
+}
+
+.phone-actions {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ max-width: 280px;
+ margin: 0 auto;
+ padding: 0 20px;
+}
+
+.action-btn {
+ width: 60px;
+ height: 60px;
+ border-radius: 50%;
+ border: none;
+ font-size: 24px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+
+ &:hover {
+ background: var(--border-color);
+ }
+
+ &:active {
+ transform: scale(0.95);
+ background: var(--border-color);
+ }
+
+ &.delete-btn {
+ color: var(--text-secondary);
+ position: relative;
+
+ &::before {
+ content: '';
+ position: absolute;
+ padding: 0 4px 0 0;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ filter: brightness(0) saturate(100%) invert(30%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(90%) contrast(90%);
+ }
+ }
+
+ &.call-btn {
+ color: var(--text-secondary);
+ color: white;
+ margin: 0 15px;
+ background: #34c759;
+
+ img {
+ width: 32px;
+ height: 32px;
+ filter: brightness(0) invert(1);
+ }
+
+ &:disabled {
+ background: #2eb350;
+ cursor: not-allowed;
+ transform: none;
+ box-shadow: none;
+ opacity: 0.5;
+ }
+
+ &:hover:not(:disabled) {
+ background: #30d158;
+ }
+
+ &:active:not(:disabled) {
+ background: #2eb350;
+ }
+ }
+
+ &.contact-btn {
+ color: var(--text-secondary);
+ background: var(--bg-secondary);
+
+ img {
+ width: 38px;
+ height: 38px;
+ filter: brightness(0) saturate(100%) invert(30%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(90%) contrast(90%);
+ }
+
+ &:hover {
+ background: var(--border-color);
+ }
+
+ &:active {
+ transform: scale(0.95);
+ background: var(--border-color);
+ }
+ }
+}
+
+/* Call Active State */
+.call-info {
+ margin-bottom: 60px;
+}
+
+.call-status {
+ font-size: 18px;
+ color: var(--text-secondary);
+ margin-bottom: 20px;
+}
+
+.call-number {
+ font-size: 36px;
+ font-weight: 300;
+ color: var(--text-primary);
+ margin-bottom: 10px;
+}
+
+.call-duration {
+ font-size: 20px;
+ color: var(--text-secondary);
+}
+
+.call-actions {
+ display: flex;
+ justify-content: center;
+}
+
+.end-call-btn {
+ width: 64px;
+ height: 64px;
+ border-radius: 50%;
+ border: none;
+ background: #ff3b30;
+ color: white;
+ font-size: 28px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ box-shadow: 0 4px 12px rgba(255, 59, 48, 0.2);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ padding: 0;
+
+ img {
+ width: 32px;
+ height: 32px;
+ filter: brightness(0) invert(1);
+ }
+
+ &:hover {
+ background: #ff453a;
+ transform: scale(1.1);
+ box-shadow: 0 6px 16px rgba(255, 59, 48, 0.3);
+ }
+
+ &:active {
+ transform: scale(0.9);
+ background: #ff2d55;
+ }
+
+ &::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: linear-gradient(135deg,
+ rgba(255, 255, 255, 0.1) 0%,
+ rgba(255, 255, 255, 0) 40%,
+ rgba(0, 0, 0, 0.05) 100%);
+ border-radius: 50%;
+ opacity: 0.5;
+ }
+}
+
+/* ---- ../styles/components/messages.css ---- */
+/* Messages App */
+.messages-list {
+ padding: 0;
+ margin-bottom: 0;
+
+ .message-item {
+ display: flex;
+ align-items: center;
+ padding: 15px 20px;
+ border-bottom: 1px solid var(--border-color);
+ cursor: pointer;
+ transition: background-color 0.2s;
+ background: var(--bg-primary);
+
+ &:hover {
+ background-color: var(--bg-secondary);
+ }
+
+ .message-avatar {
+ width: 50px;
+ height: 50px;
+ border-radius: 25px;
+ background: linear-gradient(45deg, #34c759, #30d158);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ font-weight: bold;
+ margin-right: 15px;
+ font-size: 16px;
+ }
+
+ .message-content {
+ flex: 1;
+ min-width: 0;
+
+ .message-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 4px;
+
+ .contact-name {
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--text-primary);
+ }
+
+ .message-time {
+ font-size: 12px;
+ color: var(--text-secondary);
+ }
+ }
+
+ .message-preview {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+
+ p {
+ font-size: 14px;
+ color: var(--text-secondary);
+ margin: 0;
+ flex: 1;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .unread-badge {
+ background: #ff3b30;
+ color: white;
+ border-radius: 10px;
+ padding: 2px 6px;
+ font-size: 12px;
+ font-weight: bold;
+ min-width: 18px;
+ text-align: center;
+ margin-left: 8px;
+ }
+ }
+ }
+
+ .message-thread-delete-button {
+ border: 1px solid rgba(255, 59, 48, 0.55);
+ border-radius: 10px;
+ background: rgba(255, 59, 48, 0.14);
+ color: #ff6b61;
+ cursor: pointer;
+ flex-shrink: 0;
+ font-size: 12px;
+ font-weight: 700;
+ margin-left: 10px;
+ padding: 7px 9px;
+ }
+
+ .message-thread-delete-button:hover {
+ background: rgba(255, 59, 48, 0.22);
+ }
+ }
+}
+
+.message-nav-delete-button {
+ border: 0;
+ border-radius: 10px;
+ background: rgba(255, 59, 48, 0.18);
+ color: #ff6b61;
+ cursor: pointer;
+ font: inherit;
+ font-size: 12px;
+ font-weight: 700;
+ padding: 7px 10px;
+}
+
+.message-nav-delete-button:hover {
+ background: rgba(255, 59, 48, 0.28);
+}
+
+.messages-empty-state {
+ align-items: center;
+ color: var(--text-secondary);
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ justify-content: center;
+ min-height: 190px;
+ text-align: center;
+}
+
+.messages-empty-state strong {
+ color: var(--text-primary);
+ font-size: 16px;
+}
+
+/* Conversation View */
+.conversation-view {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ margin-bottom: 25px;
+ overflow: hidden;
+
+ .conversation-header {
+ background: #34c759;
+ color: white;
+ padding: 15px 20px;
+ display: flex;
+ align-items: center;
+ gap: 15px;
+ flex-shrink: 0;
+
+ .back-button {
+ background: none;
+ border: none;
+ color: white;
+ font-size: 16px;
+ cursor: pointer;
+ padding: 5px 10px;
+ border-radius: 4px;
+ transition: background-color 0.2s;
+
+ &:hover {
+ background: rgba(255, 255, 255, 0.2);
+ }
+ }
+
+ h2 {
+ font-size: 18px;
+ font-weight: 600;
+ }
+ }
+
+ .messages-container {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 10px;
+ box-sizing: border-box;
+ overflow-y: auto;
+ overflow-x: hidden;
+ min-height: 0;
+ /* Force hardware acceleration for smoother scrolling */
+ transform: translateZ(0);
+ -webkit-overflow-scrolling: touch;
+ /* Hide scrollbar but keep functionality */
+ scrollbar-width: none !important;
+ /* scrollbar-color: rgba(0, 0, 0, 0.3) rgba(0, 0, 0, 0.1); */
+
+ /* Ultra-thin scrollbar for webkit browsers */
+ /* &::-webkit-scrollbar {
+ width: 2px;
+ height: 2px;
+ }
+
+ &::-webkit-scrollbar-track {
+ background: rgba(0, 0, 0, 0.1);
+ border-radius: 1px;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background: rgba(0, 0, 0, 0.3);
+ border-radius: 1px;
+ min-height: 20px;
+
+ &:hover {
+ background: rgba(0, 0, 0, 0.5);
+ }
+ } */
+
+ .message-bubble {
+ max-width: 70%;
+ padding: 12px 16px;
+ border-radius: 18px;
+ position: relative;
+ word-wrap: break-word;
+ flex-shrink: 0;
+ margin-bottom: 8px;
+
+ &.user {
+ background: var(--message-bubble-user);
+ color: var(--message-text-user);
+ align-self: flex-end;
+ border-bottom-right-radius: 4px;
+ }
+
+ &.contact {
+ background: var(--message-bubble-contact);
+ color: var(--message-text-contact);
+ align-self: flex-start;
+ border-bottom-left-radius: 4px;
+ }
+
+ p {
+ margin: 0 0 4px 0;
+ font-size: 16px;
+ line-height: 1.4;
+ }
+
+ .message-timestamp {
+ font-size: 11px;
+ opacity: 0.7;
+ display: block;
+ margin-top: 4px;
+ }
+ }
+ }
+
+ .message-input-form {
+ background: rgba(255, 255, 255, 0.15);
+ backdrop-filter: blur(20px);
+ -webkit-backdrop-filter: blur(20px);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
+ display: flex;
+ align-items: center;
+ padding: 8px;
+ flex-shrink: 0;
+ margin-bottom: 0;
+ position: absolute;
+ bottom: 32px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: calc(100% - 24px);
+ border-radius: 20px;
+
+ .message-input {
+ flex: 1;
+ padding: 10px 16px;
+ border: none;
+ border-radius: 18px;
+ font-size: 16px;
+ outline: none;
+ background: #eee;
+ color: var(--text-primary);
+ min-height: 36px;
+ max-height: 120px;
+ line-height: 1.4;
+ resize: none;
+ overflow-y: auto;
+ box-shadow: none;
+ transition: background-color 0.2s;
+ font-family: inherit;
+
+ &:focus {
+ background: #f8f8f8;
+ }
+
+ &::placeholder {
+ color: var(--text-secondary);
+ opacity: 0.7;
+ }
+ }
+
+ .send-button {
+ width: 40px !important;
+ height: 40px !important;
+ border-radius: 50%;
+ background: var(--accent-color);
+ color: white;
+ border: none;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 18px;
+ box-shadow: 0 2px 8px rgba(0, 122, 255, 0.2);
+ transition: all 0.2s ease;
+ flex-shrink: 0;
+ padding: 0;
+ margin-left: 8px !important;
+ cursor: pointer;
+
+ &:hover {
+ transform: scale(1.05);
+ box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3);
+ }
+
+ &:active {
+ transform: scale(0.95);
+ }
+
+ svg {
+ display: block;
+ width: 22px;
+ height: 22px;
+ stroke: currentColor;
+ }
+
+ img {
+ display: block;
+ width: 22px;
+ height: 22px;
+ pointer-events: none;
+ }
+ }
+ }
+}
+
+/* Dark theme adjustments */
+[data-theme="dark"] {
+ .conversation-view {
+ .messages-container {
+ scrollbar-color: rgba(255, 255, 255, 0.3) rgba(255, 255, 255, 0.1);
+
+ &::-webkit-scrollbar-track {
+ background: rgba(255, 255, 255, 0.1);
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background: rgba(255, 255, 255, 0.3);
+
+ &:hover {
+ background: rgba(255, 255, 255, 0.5);
+ }
+ }
+ }
+ }
+
+ .message-input-form {
+ background: rgba(0, 0, 0, 0.25);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
+
+ .message-input {
+ background: rgba(255, 255, 255, 0.05);
+ color: var(--text-primary);
+
+ &:focus {
+ background: rgba(255, 255, 255, 0.08);
+ }
+ }
+ }
+}
+
+
+/* ---- ../styles/components/mail.css ---- */
+/* Mail App */
+.mail-content,
+.mail-list-container {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+.mail-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 10px;
+}
+
+.mail-item {
+ width: 100%;
+ border: 0;
+ border-bottom: 1px solid var(--border-color);
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ padding: 14px 12px;
+ text-align: left;
+ cursor: pointer;
+}
+
+.mail-item.unread {
+ font-weight: 700;
+}
+
+.mail-item.read {
+ opacity: 0.74;
+}
+
+.mail-item-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 10px;
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.mail-item-subject {
+ margin-top: 6px;
+ font-size: 16px;
+ color: var(--text-primary);
+}
+
+.mail-item-preview {
+ margin-top: 4px;
+ color: var(--text-secondary);
+ font-size: 13px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.mail-empty {
+ color: var(--text-secondary);
+ padding: 32px 16px;
+ text-align: center;
+}
+
+.mail-composer {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 14px;
+}
+
+.mail-composer label {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ color: var(--text-secondary);
+ font-size: 12px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.mail-composer input,
+.mail-composer select,
+.mail-composer textarea {
+ width: 100%;
+ border: 1px solid var(--border-color);
+ border-radius: 10px;
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ padding: 10px 12px;
+ font: inherit;
+ box-sizing: border-box;
+}
+
+.mail-composer textarea {
+ resize: none;
+}
+
+.mail-send-button {
+ border: 0;
+ border-radius: 12px;
+ background: var(--accent-color);
+ color: white;
+ cursor: pointer;
+ font-weight: 700;
+}
+
+.mail-send-button {
+ padding: 12px 14px;
+}
+
+.mail-detail {
+ padding: 16px;
+ overflow-y: auto;
+ color: var(--text-primary);
+}
+
+.mail-detail h2 {
+ margin: 0 0 12px;
+ font-size: 20px;
+}
+
+.mail-meta {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ color: var(--text-secondary);
+ font-size: 12px;
+ margin-bottom: 18px;
+}
+
+.mail-body {
+ white-space: pre-wrap;
+ line-height: 1.45;
+ margin: 0;
+}
+
+.mail-delete-button {
+ margin-top: 18px;
+ width: 100%;
+ border: 1px solid rgba(255, 59, 48, 0.55);
+ border-radius: 12px;
+ background: rgba(255, 59, 48, 0.14);
+ color: #ff6b61;
+ cursor: pointer;
+ font: inherit;
+ font-weight: 700;
+ padding: 11px 14px;
+}
+
+.mail-delete-button:hover {
+ background: rgba(255, 59, 48, 0.22);
+}
+
+
+/* ---- ../styles/components/notes.css ---- */
+/* Notes App Styles */
+
+/* Notes List */
+.notes-list {
+ padding: 0;
+ margin: 0;
+}
+
+.notes-list.empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 60vh;
+}
+
+.notes-empty-state {
+ text-align: center;
+ color: var(--text-secondary);
+ padding: 2rem;
+}
+
+.notes-empty-state .empty-icon {
+ margin-bottom: 1rem;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.notes-empty-state .empty-icon img {
+ width: 64px;
+ height: 64px;
+ display: block;
+}
+
+.notes-empty-state h3 {
+ margin: 0 0 0.5rem 0;
+ font-size: 1.2rem;
+ color: var(--text-primary);
+}
+
+.notes-empty-state p {
+ margin: 0;
+ font-size: 0.9rem;
+}
+
+/* Note Item */
+.note-item {
+ padding: 1rem;
+ border-bottom: 1px solid var(--border-color);
+ cursor: pointer;
+ transition: background-color 0.2s ease;
+ background: var(--background-primary);
+}
+
+.note-item:hover {
+ background: var(--background-secondary);
+}
+
+.note-item:active {
+ background: var(--background-tertiary);
+}
+
+.note-item:last-child {
+ border-bottom: none;
+}
+
+.note-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 0.5rem;
+ gap: 1rem;
+}
+
+.note-title {
+ margin: 0;
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ line-height: 1.3;
+ flex: 1;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.note-date {
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.note-preview {
+ margin: 0;
+ font-size: 0.9rem;
+ color: var(--text-secondary);
+ line-height: 1.4;
+ display: -webkit-box;
+ line-clamp: 3;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+/* Note Editor */
+.note-editor {
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+.editor-content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ padding: 1rem;
+ gap: 1rem;
+ overflow: hidden;
+}
+
+.note-title-input {
+ border: none;
+ background: transparent;
+ font-size: 1.5rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ padding: 0;
+ margin: 0;
+ outline: none;
+ width: 100%;
+}
+
+.note-title-input::placeholder {
+ color: var(--text-secondary);
+ opacity: 0.7;
+}
+
+.note-content-input {
+ flex: 1;
+ border: none;
+ background: transparent;
+ font-size: 1rem;
+ color: var(--text-primary);
+ padding: 0;
+ margin: 0;
+ outline: none;
+ resize: none;
+ font-family: inherit;
+ line-height: 1.5;
+ overflow-y: auto;
+}
+
+.note-content-input::placeholder {
+ color: var(--text-secondary);
+ opacity: 0.7;
+}
+
+.editor-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.5rem 0;
+ border-top: 1px solid var(--border-color);
+ margin-top: auto;
+ flex-shrink: 0;
+}
+
+.editor-status {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+}
+
+.word-count {
+ color: var(--text-secondary);
+}
+
+.modified-indicator {
+ color: var(--accent-color);
+ font-weight: 500;
+}
+
+.delete-button {
+ background: transparent;
+ border: none;
+ color: #ff4444;
+ font-size: 0.9rem;
+ padding: 0.5rem 1rem;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.delete-button:hover {
+ background: rgba(255, 68, 68, 0.1);
+}
+
+.delete-button:active {
+ background: rgba(255, 68, 68, 0.2);
+}
+
+/* Navigation Buttons - Note Editor specific */
+.note-editor .navigation-bar .nav-button.cancel-button {
+ color: var(--text-secondary) !important;
+ font-size: 0.9rem !important;
+ font-weight: 400 !important;
+ padding: 0.5rem 1rem !important;
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ transition: color 0.2s ease;
+}
+
+.note-editor .navigation-bar .nav-button.cancel-button:hover {
+ color: var(--text-primary) !important;
+}
+
+.note-editor .navigation-bar .nav-button.save-button {
+ color: var(--accent-color) !important;
+ font-size: 0.9rem !important;
+ font-weight: 600 !important;
+ padding: 0.5rem 1rem !important;
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ transition: color 0.2s ease;
+}
+
+.note-editor .navigation-bar .nav-button.save-button:hover {
+ color: var(--accent-color-hover) !important;
+}
+
+/* Dark theme adjustments */
+[data-theme="dark"] .note-item {
+ border-bottom-color: rgba(255, 255, 255, 0.1);
+}
+
+[data-theme="dark"] .editor-footer {
+ border-top-color: rgba(255, 255, 255, 0.1);
+}
+
+/* Light theme adjustments */
+[data-theme="light"] .note-item {
+ border-bottom-color: rgba(0, 0, 0, 0.1);
+}
+
+[data-theme="light"] .editor-footer {
+ border-top-color: rgba(0, 0, 0, 0.1);
+}
+
+/* Focus states */
+.note-item:focus {
+ outline: 2px solid var(--accent-color);
+ outline-offset: -2px;
+}
+
+.note-title-input:focus,
+.note-content-input:focus {
+ outline: none;
+}
+
+/* Responsive adjustments */
+@media (max-width: 375px) {
+ .note-header {
+ gap: 0.5rem;
+ }
+
+ .note-title {
+ font-size: 0.95rem;
+ }
+
+ .note-preview {
+ font-size: 0.85rem;
+ }
+
+ .editor-content {
+ padding: 0.75rem;
+ }
+
+ .note-title-input {
+ font-size: 1.3rem;
+ }
+}
+
+
+
+/* ---- ../styles/components/clock.css ---- */
+/* Clock App Styles */
+
+/* Clock Tabs */
+.clock-tabs {
+ display: flex;
+ background: var(--background-secondary);
+ border-bottom: 1px solid var(--border-color);
+ margin-bottom: 1rem;
+}
+
+.clock-tab {
+ flex: 1;
+ padding: 0.75rem 0.5rem;
+ background: none;
+ border: none;
+ color: var(--text-secondary);
+ font-size: 0.8rem;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ border-bottom: 2px solid transparent;
+}
+
+.clock-tab.active {
+ color: var(--accent-color);
+ border-bottom-color: var(--accent-color);
+ background: var(--bg-primary);
+}
+
+.clock-tab:hover {
+ color: var(--text-primary);
+ background: var(--background-tertiary);
+}
+
+.clock-content {
+ padding: 0 1rem;
+ overflow-y: auto;
+ max-height: calc(100vh - 200px);
+}
+
+/* World Clock */
+.world-clock {
+ padding-bottom: 2rem;
+}
+
+.local-time-section {
+ text-align: center;
+ margin-bottom: 2rem;
+ padding: 1.5rem;
+ background: var(--background-secondary);
+ border-radius: 12px;
+}
+
+.local-time-label {
+ margin: 0 0 1rem 0;
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.local-time-display {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.local-time {
+ font-size: 2.5rem;
+ font-weight: 300;
+ color: var(--accent-color);
+ font-family: 'SF Mono', monospace;
+}
+
+.local-date {
+ font-size: 1rem;
+ color: var(--text-secondary);
+}
+
+.add-world-clock-button {
+ width: 100%;
+ padding: 0.75rem;
+ margin-bottom: 1rem;
+ background: var(--accent-color);
+ color: white;
+ border: none;
+ border-radius: 8px;
+ font-size: 0.9rem;
+ cursor: pointer;
+ transition: background 0.2s ease;
+}
+
+.add-world-clock-button:hover {
+ background: var(--accent-color-hover);
+}
+
+.add-clock-form {
+ background: var(--background-secondary);
+ padding: 1rem;
+ border-radius: 8px;
+ margin-bottom: 1rem;
+}
+
+.add-clock-form h3 {
+ margin: 0 0 1rem 0;
+ font-size: 1rem;
+ color: var(--text-primary);
+}
+
+.timezone-select {
+ width: 100%;
+ padding: 0.75rem;
+ margin-bottom: 1rem;
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ font-size: 0.9rem;
+}
+
+.form-buttons {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.form-buttons button {
+ flex: 1;
+ padding: 0.5rem;
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.form-buttons .add-button {
+ background: var(--accent-color);
+ color: white;
+ border-color: var(--accent-color);
+}
+
+.form-buttons .add-button:hover {
+ background: var(--accent-color-hover);
+}
+
+.form-buttons .add-button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.world-clocks-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.world-clock-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1rem;
+ background: var(--background-secondary);
+ border-radius: 8px;
+ gap: 1rem;
+}
+
+.clock-info {
+ flex: 1;
+}
+
+.clock-city {
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 0.25rem;
+}
+
+.clock-timezone {
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+}
+
+.clock-time-info {
+ text-align: right;
+}
+
+.clock-time {
+ font-size: 1.2rem;
+ font-weight: 500;
+ color: var(--accent-color);
+ font-family: 'SF Mono', monospace;
+}
+
+.clock-date {
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+}
+
+.remove-clock-button {
+ background: none;
+ border: 1px solid #ff4444;
+ color: #ff4444;
+ font-size: 0.8rem;
+ cursor: pointer;
+ padding: 0.5rem 0.75rem;
+ border-radius: 4px;
+ transition: all 0.2s ease;
+}
+
+.remove-clock-button:hover {
+ background: #ff4444;
+ color: white;
+}
+
+/* Stopwatch */
+.stopwatch {
+ text-align: center;
+ padding: 2rem 1rem;
+}
+
+.stopwatch-display {
+ margin-bottom: 2rem;
+}
+
+.stopwatch-time {
+ font-size: 3rem;
+ font-weight: 300;
+ font-family: 'SF Mono', monospace;
+ color: var(--text-primary);
+ margin-bottom: 0.5rem;
+}
+
+.stopwatch-time.running {
+ color: var(--accent-color);
+}
+
+.stopwatch-status {
+ font-size: 0.9rem;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 1px;
+}
+
+.stopwatch-controls {
+ display: flex;
+ justify-content: center;
+ gap: 1rem;
+ margin-bottom: 2rem;
+}
+
+.control-button {
+ padding: 0.75rem 1.5rem;
+ border: none;
+ border-radius: 25px;
+ font-size: 0.9rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ min-width: 80px;
+}
+
+.start-button {
+ background: #4CAF50;
+ color: white;
+}
+
+.start-button:hover {
+ background: #45a049;
+}
+
+.stop-button {
+ background: #f44336;
+ color: white;
+}
+
+.stop-button:hover {
+ background: #da190b;
+}
+
+.lap-button {
+ background: var(--accent-color);
+ color: white;
+}
+
+.lap-button:hover {
+ background: var(--accent-color-hover);
+}
+
+.reset-button {
+ background: var(--text-secondary);
+ color: white;
+}
+
+.reset-button:hover {
+ background: var(--text-primary);
+}
+
+.lap-times-section {
+ text-align: left;
+}
+
+.lap-times-title {
+ margin: 0 0 1rem 0;
+ font-size: 1.1rem;
+ color: var(--text-primary);
+ text-align: center;
+}
+
+.lap-times-list {
+ max-height: 300px;
+ overflow-y: auto;
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+}
+
+.lap-time-item {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr auto;
+ gap: 1rem;
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid var(--border-color);
+ align-items: center;
+ font-family: 'SF Mono', monospace;
+ font-size: 0.9rem;
+}
+
+.lap-time-item:last-child {
+ border-bottom: none;
+}
+
+.lap-time-item.fastest {
+ background: rgba(76, 175, 80, 0.1);
+ color: #4CAF50;
+}
+
+.lap-time-item.slowest {
+ background: rgba(244, 67, 54, 0.1);
+ color: #f44336;
+}
+
+.lap-number {
+ font-weight: 600;
+}
+
+.lap-indicator {
+ font-size: 0.7rem;
+ text-transform: uppercase;
+ font-weight: 600;
+ letter-spacing: 0.5px;
+}
+
+/* Timer */
+.timer {
+ text-align: center;
+ padding: 2rem 1rem;
+}
+
+.timer-display {
+ margin-bottom: 2rem;
+}
+
+.timer-time {
+ font-size: 3rem;
+ font-weight: 300;
+ font-family: 'SF Mono', monospace;
+ color: var(--text-primary);
+ margin-bottom: 0.5rem;
+}
+
+.timer-time.finished {
+ color: #f44336;
+ animation: pulse 1s infinite;
+}
+
+@keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.5; }
+}
+
+.timer-status {
+ font-size: 0.9rem;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 1px;
+}
+
+.time-setters {
+ display: flex;
+ justify-content: center;
+ gap: 2rem;
+ margin-bottom: 2rem;
+}
+
+.time-setter {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.time-setter label {
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.time-setter input {
+ width: 60px;
+ padding: 0.5rem;
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ text-align: center;
+ font-size: 1.1rem;
+ font-family: 'SF Mono', monospace;
+}
+
+.timer-controls {
+ display: flex;
+ justify-content: center;
+ gap: 1rem;
+}
+
+/* Alarm Clock */
+.alarm-clock {
+ padding-bottom: 2rem;
+}
+
+.add-alarm-button {
+ width: 100%;
+ padding: 0.75rem;
+ margin-bottom: 1rem;
+ background: var(--accent-color);
+ color: white;
+ border: none;
+ border-radius: 8px;
+ font-size: 0.9rem;
+ cursor: pointer;
+ transition: background 0.2s ease;
+}
+
+.add-alarm-button:hover {
+ background: var(--accent-color-hover);
+}
+
+.add-alarm-form {
+ background: var(--background-secondary);
+ padding: 1rem;
+ border-radius: 8px;
+ margin-bottom: 1rem;
+}
+
+.add-alarm-form h3 {
+ margin: 0 0 1rem 0;
+ font-size: 1rem;
+ color: var(--text-primary);
+}
+
+.add-alarm-form input {
+ width: 100%;
+ padding: 0.75rem;
+ margin-bottom: 1rem;
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ font-size: 0.9rem;
+}
+
+.alarms-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.alarm-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1rem;
+ background: var(--background-secondary);
+ border-radius: 8px;
+ gap: 1rem;
+ opacity: 1;
+ transition: opacity 0.2s ease;
+}
+
+.alarm-item.disabled {
+ opacity: 0.6;
+}
+
+.alarm-info {
+ flex: 1;
+}
+
+.alarm-time {
+ font-size: 1.4rem;
+ font-weight: 500;
+ color: var(--text-primary);
+ font-family: 'SF Mono', monospace;
+ margin-bottom: 0.25rem;
+}
+
+.alarm-label {
+ font-size: 0.9rem;
+ color: var(--text-secondary);
+ margin-bottom: 0.25rem;
+}
+
+.alarm-days {
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+}
+
+.alarm-controls {
+ display: flex;
+ gap: 0.5rem;
+ align-items: center;
+}
+
+.toggle-alarm {
+ padding: 0.5rem 1rem;
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ cursor: pointer;
+ font-size: 0.8rem;
+ transition: all 0.2s ease;
+}
+
+.alarm-item.enabled .toggle-alarm {
+ background: var(--accent-color);
+ color: white;
+ border-color: var(--accent-color);
+}
+
+.remove-alarm {
+ background: none;
+ border: 1px solid #ff4444;
+ color: #ff4444;
+ font-size: 0.8rem;
+ cursor: pointer;
+ padding: 0.5rem 0.75rem;
+ border-radius: 4px;
+ transition: all 0.2s ease;
+}
+
+.remove-alarm:hover {
+ background: #ff4444;
+ color: white;
+}
+
+/* Empty States */
+.empty-state {
+ text-align: center;
+ padding: 2rem;
+ color: var(--text-secondary);
+}
+
+/* Responsive */
+@media (max-width: 375px) {
+ .clock-tabs {
+ font-size: 0.7rem;
+ }
+
+ .clock-tab {
+ padding: 0.5rem 0.25rem;
+ }
+
+ .local-time {
+ font-size: 2rem;
+ }
+
+ .stopwatch-time,
+ .timer-time {
+ font-size: 2.5rem;
+ }
+
+ .time-setters {
+ gap: 1rem;
+ }
+}
+
+
+
+/* ---- ../styles/components/calendar.css ---- */
+/* Calendar App Styles */
+.app-container {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ background: var(--bg-primary);
+}
+
+.content {
+ flex: 1;
+ overflow-y: auto;
+ padding: 16px;
+}
+
+/* Calendar Container */
+.calendar-container {
+ background: var(--bg-primary);
+ border-radius: 12px;
+ width: 100%;
+ max-width: 375px;
+ margin: 0 auto;
+}
+
+/* Calendar Header */
+.calendar-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin: 10px;
+ padding: 0 16px;
+}
+
+.calendar-title {
+ font-size: 20px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.calendar-nav {
+ display: flex;
+ gap: 8px;
+}
+
+.calendar-nav-btn {
+ background: var(--bg-secondary);
+ border: none;
+ border-radius: 8px;
+ padding: 8px;
+ color: var(--accent-color);
+ font-weight: 500;
+ cursor: pointer;
+ transition: background-color 0.2s;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 36px;
+ height: 36px;
+}
+
+.calendar-nav-btn img {
+ width: 20px;
+ height: 20px;
+ color: var(--accent-color);
+}
+
+.calendar-nav-btn:hover {
+ background: var(--border-color);
+}
+
+.nav-button.add-button img {
+ width: 24px;
+ height: 24px;
+ color: var(--accent-color);
+}
+
+/* Calendar Grid */
+.calendar-grid {
+ display: grid;
+ grid-template-columns: repeat(7, 1fr);
+ gap: 4px;
+ padding: 0 16px;
+}
+
+.calendar-weekday {
+ text-align: center;
+ font-size: 12px;
+ color: var(--text-secondary);
+ padding: 8px 0;
+ font-weight: 500;
+}
+
+.calendar-day {
+ aspect-ratio: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ font-size: 14px;
+ cursor: pointer;
+ transition: all 0.2s;
+ position: relative;
+ border: 2px solid transparent;
+}
+
+.calendar-day:hover {
+ background: var(--bg-secondary);
+}
+
+.calendar-day.today {
+ background: var(--accent-color);
+ color: white;
+ border: 2px solid transparent;
+}
+
+.calendar-day.selected {
+ background: var(--accent-color);
+ color: white;
+}
+
+.calendar-day.today:not(.selected) {
+ background: transparent;
+ color: var(--text-primary);
+ border: 2px solid var(--accent-color);
+}
+
+.calendar-day.today.selected {
+ border: 2px solid white;
+}
+
+.calendar-day.other-month {
+ color: var(--text-secondary);
+ opacity: 0.5;
+}
+
+.calendar-day.has-events::after {
+ content: '';
+ position: absolute;
+ bottom: 4px;
+ width: 4px;
+ height: 4px;
+ border-radius: 50%;
+ background: var(--accent-color);
+}
+
+/* Calendar Events */
+.calendar-events {
+ margin-top: 20px;
+ border-top: 1px solid var(--border-color);
+ padding: 16px;
+}
+
+.no-events {
+ text-align: center;
+ color: var(--text-secondary);
+ font-size: 14px;
+ padding: 20px 0;
+}
+
+.event-item {
+ display: flex;
+ align-items: center;
+ padding: 12px;
+ border-radius: 8px;
+ margin-bottom: 8px;
+ background: var(--bg-secondary);
+ cursor: pointer;
+ transition: background-color 0.2s;
+}
+
+.event-item:hover {
+ background: var(--border-color);
+}
+
+.event-time {
+ font-size: 12px;
+ color: var(--text-secondary);
+ margin-right: 12px;
+ min-width: 60px;
+}
+
+.event-title {
+ font-size: 14px;
+ color: var(--text-primary);
+ flex-grow: 1;
+}
+
+.event-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--accent-color);
+ margin-right: 8px;
+}
+
+/* Event Editor */
+.event-editor {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ background: var(--bg-primary);
+}
+
+.event-form {
+ display: flex;
+ flex-direction: column;
+ padding: 16px;
+ gap: 16px;
+}
+
+.event-title-input {
+ font-size: 20px;
+ padding: 8px 0;
+ border: none;
+ border-bottom: 1px solid var(--border-color);
+ background: none;
+ color: var(--text-primary);
+ outline: none;
+}
+
+.time-container {
+ display: flex;
+ gap: 16px;
+}
+
+.time-input {
+ flex: 1;
+ padding: 8px;
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ font-size: 14px;
+}
+
+.event-description-input {
+ min-height: 100px;
+ padding: 8px;
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ font-size: 14px;
+ resize: none;
+}
+
+.delete-event-button {
+ background: transparent;
+ border: none;
+ color: #ff4444;
+ font-size: 0.9rem;
+ padding: 0.5rem 1rem;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.delete-event-button:hover {
+ background: rgba(255, 68, 68, 0.1);
+}
+
+.delete-event-button:active {
+ background: rgba(255, 68, 68, 0.2);
+}
+
+/* Navigation Buttons - Event Editor specific */
+.event-editor .navigation-bar .nav-button.cancel-button {
+ color: var(--text-secondary) !important;
+ font-size: 0.9rem !important;
+ font-weight: 400 !important;
+ padding: 0.5rem 1rem !important;
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ transition: color 0.2s ease;
+}
+
+.event-editor .navigation-bar .nav-button.cancel-button:hover {
+ color: var(--text-primary) !important;
+}
+
+.event-editor .navigation-bar .nav-button.save-button {
+ color: var(--accent-color) !important;
+ font-size: 0.9rem !important;
+ font-weight: 600 !important;
+ padding: 0.5rem 1rem !important;
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ transition: color 0.2s ease;
+}
+
+.event-editor .navigation-bar .nav-button.save-button:hover {
+ color: var(--accent-color-hover) !important;
+}
+
+/* ---- ../styles/components/settings.css ---- */
+/* Settings */
+.settings-list {
+ background: var(--bg-primary);
+}
+
+.settings-item {
+ border-bottom: 1px solid var(--border-color);
+ color: var(--text-primary);
+}
+
+.settings-item:hover {
+ background: var(--bg-secondary);
+}
+
+/* Theme Toggle Switch */
+.theme-toggle {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 15px 20px;
+ border-bottom: 1px solid var(--border-color);
+ background: var(--bg-primary);
+ color: var(--text-primary);
+}
+
+.toggle-switch {
+ position: relative;
+ width: 51px;
+ height: 31px;
+}
+
+.toggle-switch input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+.toggle-slider {
+ position: absolute;
+ cursor: pointer;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: #e9ecef;
+ transition: .4s;
+ border-radius: 34px;
+}
+
+.toggle-slider:before {
+ position: absolute;
+ content: "";
+ height: 27px;
+ width: 27px;
+ left: 2px;
+ bottom: 2px;
+ background-color: white;
+ transition: .4s;
+ border-radius: 50%;
+}
+
+input:checked+.toggle-slider {
+ background-color: var(--accent-color);
+}
+
+input:checked+.toggle-slider:before {
+ transform: translateX(20px);
+}
+
+/* ---- ../styles/components/loader.css ---- */
+#script-loader {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.8);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 9999;
+}
+
+.loader-content {
+ text-align: center;
+ color: white;
+}
+
+.spinner {
+ width: 50px;
+ height: 50px;
+ border: 5px solid #f3f3f3;
+ border-top: 5px solid #3498db;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ margin: 0 auto 20px;
+}
+
+@keyframes spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+}
+
+.script-load-error {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.9);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 9999;
+}
+
+.error-content {
+ background: white;
+ padding: 30px;
+ border-radius: 10px;
+ text-align: center;
+ max-width: 400px;
+ margin: 20px;
+}
+
+.error-content h2 {
+ color: #e74c3c;
+ margin-bottom: 15px;
+}
+
+.error-content button {
+ background: #3498db;
+ color: white;
+ border: none;
+ padding: 10px 20px;
+ border-radius: 5px;
+ cursor: pointer;
+ margin-top: 15px;
+ font-size: 16px;
+}
+
+.error-content button:hover {
+ background: #2980b9;
+}
diff --git a/arma/client/addons/phone/ui/_site/dist/app.bundle.js b/arma/client/addons/phone/ui/_site/dist/app.bundle.js
new file mode 100644
index 0000000..d2d2b3a
--- /dev/null
+++ b/arma/client/addons/phone/ui/_site/dist/app.bundle.js
@@ -0,0 +1,7928 @@
+
+// ---- ../js/core/Component.js ----
+/** @format */
+
+/**
+ * @fileoverview Core Component class that provides the foundation for all UI components
+ * in the phone application. Implements a lightweight component lifecycle and virtual DOM-like
+ * functionality without external dependencies.
+ */
+
+/**
+ * Base Component class that handles rendering, lifecycle events, and state management.
+ * Provides a React-like component interface for building UI elements.
+ *
+ * @class
+ * @example
+ * class MyComponent extends Component {
+ * constructor(props) {
+ * super(props);
+ * this.state = { count: 0 };
+ * }
+ *
+ * render() {
+ * return this.createElement('div', {}, `Count: ${this.state.count}`);
+ * }
+ * }
+ */
+class Component {
+ /**
+ * Creates a new Component instance.
+ * @param {Object} props - Initial properties for the component
+ */
+ constructor(props = {}) {
+ this.props = props;
+ this.state = {};
+ this.element = null;
+ this.children = [];
+ this.eventListeners = new Map();
+ this.mounted = false;
+ this.pendingUpdate = false;
+ }
+
+ /**
+ * Updates component state and triggers a re-render.
+ * State updates are batched to prevent multiple renders in the same tick.
+ *
+ * @param {Object} newState - Object containing state updates
+ */
+ setState(newState) {
+ const prevState = { ...this.state };
+ this.state = { ...this.state, ...newState };
+
+ // Prevent multiple updates in the same tick
+ if (!this.pendingUpdate) {
+ this.pendingUpdate = true;
+ setTimeout(() => {
+ this.pendingUpdate = false;
+ this.updateComponent(prevState);
+ }, 0);
+ }
+ }
+
+ /**
+ * Internal method to handle component updates.
+ * Manages the re-rendering process and maintains child component state.
+ *
+ * @private
+ * @param {Object} prevState - Previous state before update
+ */
+ updateComponent(prevState) {
+ // Call onStateChange hook
+ this.onStateChange(prevState, this.state);
+
+ // Re-render and update DOM
+ if (this.element && this.element.parentNode) {
+ const container = this.element.parentNode;
+ const oldElement = this.element;
+
+ // Store input states and elements before update
+ const inputStates = new Map();
+ oldElement.querySelectorAll('input').forEach(input => {
+ inputStates.set(input, {
+ element: input,
+ value: input.value,
+ selectionStart: input.selectionStart,
+ selectionEnd: input.selectionEnd,
+ isFocused: document.activeElement === input
+ });
+ });
+
+ // Store mounted state of children
+ const childStates = new Map();
+ this.children.forEach((child) => {
+ childStates.set(child, child.mounted);
+ });
+
+ // Create new element
+ const newElement = this.render();
+
+ // Update the DOM while preserving input elements
+ if (oldElement && newElement) {
+ // Replace the old element with the new one
+ container.replaceChild(newElement, oldElement);
+ this.element = newElement;
+
+ // Restore input elements and their states
+ inputStates.forEach((state, oldInput) => {
+ const newInput = newElement.querySelector(`input[type="${oldInput.type}"]`);
+ if (newInput) {
+ // Replace the new input with the old one
+ newInput.parentNode.replaceChild(oldInput, newInput);
+
+ // Restore input state
+ if (state.isFocused) {
+ oldInput.focus();
+ oldInput.setSelectionRange(state.selectionStart, state.selectionEnd);
+ }
+ }
+ });
+
+ // Restore child components that were previously mounted
+ this.children.forEach((child) => {
+ if (childStates.get(child)) {
+ child.mount(this.element);
+ }
+ });
+ }
+ }
+ }
+
+ /**
+ * Lifecycle method called when state changes.
+ * Override in subclasses to handle state updates.
+ *
+ * @param {Object} prevState - Previous state
+ * @param {Object} newState - New state
+ */
+ onStateChange(prevState, newState) {
+ // Override in subclasses if needed
+ }
+
+ /**
+ * Mounts the component to a DOM container.
+ * Handles initial render and lifecycle methods.
+ *
+ * @param {HTMLElement} container - DOM element to mount component into
+ * @returns {Component} The component instance
+ */
+ mount(container) {
+ // Skip if already mounted to this container
+ if (this.mounted && this.element && this.element.parentNode === container) {
+ return this;
+ }
+
+ const newElement = this.render();
+ if (this.element && this.element.parentNode) {
+ this.element.parentNode.replaceChild(newElement, this.element);
+ } else {
+ container.appendChild(newElement);
+ }
+ this.element = newElement;
+
+ // Call componentDidMount after mounting
+ if (!this.mounted && this.componentDidMount) {
+ this.componentDidMount();
+ }
+ this.mounted = true;
+ return this;
+ }
+
+ /**
+ * Creates a DOM element with specified properties and children.
+ * Handles event listeners, styles, and refs.
+ *
+ * @param {string} tag - HTML tag name
+ * @param {Object} props - Element properties and attributes
+ * @param {...(string|number|Component|HTMLElement)} children - Child elements
+ * @returns {HTMLElement} Created DOM element
+ */
+ createElement(tag, props = {}, ...children) {
+ const element = document.createElement(tag);
+
+ // Set attributes and properties
+ Object.entries(props).forEach(([key, value]) => {
+ if (key.startsWith('on') && typeof value === 'function') {
+ const event = key.slice(2).toLowerCase();
+ element.addEventListener(event, value);
+
+ // Store event listener for cleanup
+ if (!this.eventListeners.has(element)) {
+ this.eventListeners.set(element, []);
+ }
+ this.eventListeners.get(element).push({ event, handler: value });
+ } else if (key === 'className') {
+ element.className = value;
+ } else if (key === 'style' && typeof value === 'object') {
+ Object.assign(element.style, value);
+ } else if (key === 'ref' && typeof value === 'function') {
+ value(element);
+ } else if (typeof value === 'boolean') {
+ if (value) {
+ element.setAttribute(key, key);
+ }
+ } else if (value !== null && value !== undefined) {
+ element.setAttribute(key, value);
+ } else {
+ return;
+ }
+ });
+
+ // Add children
+ children.flat().forEach((child) => {
+ if (child === null || child === undefined) {
+ return;
+ }
+
+ if (typeof child === 'string' || typeof child === 'number') {
+ element.appendChild(document.createTextNode(child));
+ } else if (child instanceof Component) {
+ child.mount(element);
+ this.children.push(child);
+ } else if (child instanceof HTMLElement) {
+ element.appendChild(child);
+ }
+ });
+
+ return element;
+ }
+
+ /**
+ * Renders the component's DOM representation.
+ * Must be overridden by subclasses to define component structure.
+ *
+ * @returns {HTMLElement} The rendered DOM element
+ */
+ render() {
+ // Override in subclasses
+ return this.createElement('div');
+ }
+
+ /**
+ * Unmounts the component and cleans up resources.
+ * Removes event listeners and unmounts children.
+ */
+ unmount() {
+ // Call componentWillUnmount before cleanup
+ if (this.mounted && this.componentWillUnmount) {
+ this.componentWillUnmount();
+ }
+
+ // Clean up event listeners
+ this.eventListeners.forEach((listeners, element) => {
+ listeners.forEach(({ event, handler }) => {
+ element.removeEventListener(event, handler);
+ });
+ });
+ this.eventListeners.clear();
+
+ // Unmount children
+ this.children.forEach((child) => {
+ if (child.mounted) {
+ child.unmount();
+ }
+ });
+ this.children = [];
+
+ // Remove from DOM
+ if (this.element && this.element.parentNode) {
+ this.element.parentNode.removeChild(this.element);
+ }
+ this.element = null;
+ this.mounted = false;
+ }
+}
+
+
+// ---- ../js/core/StateManager.js ----
+/**
+ * @format
+ * @fileoverview State management system for the phone application. Implements a simple pub/sub pattern for managing global application state.
+ */
+
+/**
+ * Initial application state containing mock data for development.
+ * @type {Object}
+ */
+const initialAppState = {
+ // Navigation state
+ currentApp: 'home',
+ showModal: false,
+
+ // Contact management
+ contacts: [],
+
+ // Message management
+ messages: [],
+
+ // Server-synced data (non-UI mapped)
+ // Keep raw server payloads separate to avoid breaking current UI
+ rawMessages: [],
+ emails: [],
+ selectedEmail: null,
+ showEmailComposer: false,
+ selectedConversationRaw: null,
+
+ // UI state
+ selectedContact: null,
+ selectedConversation: null,
+ showMessageContactPicker: false,
+ newMessage: '',
+ currentUid: null,
+
+ // Clock state
+ clockMode: 'world',
+ worldClocks: [],
+ timers: [],
+ alarms: [],
+ clockSettings: { format24h: true },
+
+ // Notes state
+ notes: [],
+ currentNote: null,
+ showNoteEditor: false,
+
+ // Calendar state
+ events: [],
+ currentEvent: null,
+ showEventEditor: false,
+};
+
+/**
+ * Manages global application state using a publish/subscribe pattern.
+ * Provides methods for accessing and updating state while notifying subscribers.
+ *
+ * @class
+ * @example
+ * const state = new StateManager({ count: 0 });
+ * state.subscribe((newState, prevState) => {
+ * console.log('State changed:', newState);
+ * });
+ * state.setState({ count: 1 });
+ */
+class StateManager {
+ /**
+ * Creates a new StateManager instance.
+ * @param {Object} initialState - Initial state object
+ */
+ constructor(initialState = {}) {
+ /** @private */
+ this.state = { ...initialState };
+ /** @private */
+ this.subscribers = new Set();
+ }
+
+ /**
+ * Gets current state object.
+ * @returns {Object} Copy of current state
+ */
+ getState() {
+ return { ...this.state };
+ }
+
+ /**
+ * Updates state and notifies subscribers.
+ * @param {Object} updates - Object containing state updates
+ */
+ setState(updates) {
+ const prevState = { ...this.state };
+ this.state = { ...this.state, ...updates };
+ this.notifySubscribers(prevState, this.state);
+ }
+
+ /**
+ * Subscribes to state changes.
+ * @param {Function} callback - Function to call when state changes
+ * @returns {Function} Unsubscribe function
+ */
+ subscribe(callback) {
+ this.subscribers.add(callback);
+ return () => this.subscribers.delete(callback);
+ }
+
+ /**
+ * Notifies subscribers of state changes.
+ * @private
+ * @param {Object} prevState - Previous state
+ * @param {Object} newState - New state
+ */
+ notifySubscribers(prevState, newState) {
+ this.subscribers.forEach((callback) => {
+ callback(newState, prevState);
+ });
+ }
+}
+
+// Create and export global state instance
+const globalState = new StateManager(initialAppState);
+
+
+// ---- ../js/utils/helpers.js ----
+/** @format */
+
+/**
+ * @fileoverview Utility functions for the phone application
+ * Contains helper functions for common operations like debouncing,
+ * ID generation, phone number formatting, and text manipulation.
+ */
+
+/**
+ * Creates a debounced function that delays invoking func until after wait milliseconds have elapsed
+ * @param {Function} func - The function to debounce
+ * @param {number} wait - The number of milliseconds to delay
+ * @returns {Function} The debounced function
+ */
+const debounce = (func, wait) => {
+ let timeout;
+
+ return function executedFunction(...args) {
+ const later = () => {
+ clearTimeout(timeout);
+ func(...args);
+ };
+
+ clearTimeout(timeout);
+ timeout = setTimeout(later, wait);
+ };
+};
+
+/**
+ * Generates a unique identifier using timestamp and random number.
+ *
+ * @returns {string} A unique string identifier
+ * @example
+ * const newId = generateId(); // Returns something like "lh8d3m4k2n1"
+ */
+const generateId = () => {
+ return Date.now().toString(36) + Math.random().toString(36).substr(2);
+};
+
+/**
+ * Formats a phone number string into a standardized format.
+ * Converts "11234567890" to "+1 (123) 456-7890"
+ *
+ * @param {string} phoneNumber - The raw phone number to format
+ * @returns {string} The formatted phone number
+ * @example
+ * const formatted = formatPhoneNumber('11234567890'); // Returns "+1 (123) 456-7890"
+ */
+const formatPhoneNumber = (phoneNumber) => {
+ const cleaned = phoneNumber.replace(/\D/g, '');
+ const match = cleaned.match(/^(\d{1})(\d{3})(\d{3})(\d{4})$/);
+ if (match) {
+ return `+${match[1]} (${match[2]}) ${match[3]}-${match[4]}`;
+ }
+ return phoneNumber;
+};
+
+/**
+ * Extracts initials from a person's name.
+ * Takes first letter of first and last name, up to 2 characters.
+ *
+ * @param {string} name - The full name to get initials from
+ * @returns {string} The initials (maximum 2 characters)
+ * @example
+ * const initials = getInitials('John Doe'); // Returns "JD"
+ * const singleInitial = getInitials('John'); // Returns "J"
+ */
+const getInitials = (name) => {
+ return name
+ .split(' ')
+ .map((word) => word.charAt(0).toUpperCase())
+ .join('')
+ .substring(0, 2);
+};
+
+
+// ---- ../js/utils/PhoneMedia.js ----
+/** @format */
+
+const PhoneMedia = (() => {
+ const addonRoot = 'forge\\forge_client\\addons\\phone\\ui\\_site\\';
+ const cache = new Map();
+
+ function assetPath(...parts) {
+ return `${addonRoot}${parts.join('\\')}`;
+ }
+
+ function base64Path(...parts) {
+ const path = assetPath(...parts);
+ return path.endsWith('.b64') ? path : `${path}.b64`;
+ }
+
+ function toBrowserPath(path) {
+ return String(path || '')
+ .replace(addonRoot, '')
+ .replace(/\\/g, '/')
+ .replace(/\.b64$/i, '');
+ }
+
+ function toDataUrl(base64Text, mimeType = 'image/png') {
+ const value = String(base64Text || '').trim();
+ if (!value) return '';
+ return value.startsWith('data:') ? value : `data:${mimeType};base64,${value}`;
+ }
+
+ function loadImage(path) {
+ const base64AssetPath = path.endsWith('.b64') ? path : `${path}.b64`;
+
+ if (cache.has(base64AssetPath)) {
+ return Promise.resolve(cache.get(base64AssetPath));
+ }
+
+ if (typeof A3API !== 'undefined' && A3API.RequestFile) {
+ return A3API.RequestFile(base64AssetPath).then((base64Text) => {
+ const dataUrl = toDataUrl(base64Text);
+ cache.set(base64AssetPath, dataUrl);
+ return dataUrl;
+ });
+ }
+
+ const browserPath = toBrowserPath(base64AssetPath);
+ cache.set(base64AssetPath, browserPath);
+ return Promise.resolve(browserPath);
+ }
+
+ return {
+ assetPath,
+ base64Path,
+ loadImage
+ };
+})();
+
+
+// ---- ../js/components/StatusBar.js ----
+/** @format */
+
+/**
+ * @class StatusBar
+ * @extends Component
+ * @description A component that displays the status bar at the top of the phone interface.
+ * Shows current time, signal strength, network status, and battery indicator.
+ */
+class StatusBar extends Component {
+ /**
+ * Cache for loaded icons
+ * @static
+ * @private
+ */
+ static iconCache = new Map();
+
+ /**
+ * Time update interval in milliseconds
+ * @static
+ * @private
+ */
+ static TIME_UPDATE_INTERVAL = 1000;
+
+ /**
+ * @constructor
+ * @param {Object} props - Component properties
+ */
+ constructor(props) {
+ super(props);
+ this.state = {
+ currentTime: this.getCurrentTime(),
+ };
+ this.timerInterval = null;
+ }
+
+ /**
+ * Start the timer when component mounts
+ * @lifecycle
+ */
+ componentDidMount() {
+ if (!this.timerInterval) {
+ this.timerInterval = setInterval(() => {
+ this.setState({ currentTime: this.getCurrentTime() });
+ }, StatusBar.TIME_UPDATE_INTERVAL);
+ }
+ }
+
+ /**
+ * Clean up timer when component unmounts
+ * @lifecycle
+ */
+ componentWillUnmount() {
+ if (this.timerInterval) {
+ clearInterval(this.timerInterval);
+ this.timerInterval = null;
+ }
+ }
+
+ /**
+ * Get the current time in 24-hour format
+ * @returns {string} Formatted time string (HH:mm)
+ * @private
+ */
+ getCurrentTime() {
+ return new Date().toLocaleTimeString('en-US', {
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ });
+ }
+
+ /**
+ * Render signal strength indicator
+ * @returns {HTMLElement} Signal bars element
+ * @private
+ */
+ renderSignalBars() {
+ return this.createElement(
+ 'div',
+ {
+ className: 'signal-bars',
+ 'aria-label': 'Signal strength indicator',
+ role: 'meter',
+ 'aria-valuenow': '4',
+ 'aria-valuemin': '0',
+ 'aria-valuemax': '4',
+ },
+ Array(4)
+ .fill(null)
+ .map(() =>
+ this.createElement('div', {
+ className: 'bar',
+ 'aria-hidden': 'true',
+ })
+ )
+ );
+ }
+
+ /**
+ * Render battery icon
+ * @returns {HTMLElement} Battery icon element
+ * @private
+ */
+ renderBatteryIcon() {
+ return this.createElement('span', {
+ className: 'battery-icon',
+ role: 'img',
+ 'aria-label': 'Battery full'
+ });
+ }
+
+ /**
+ * Render status indicators (network and battery)
+ * @returns {HTMLElement} Status indicators element
+ * @private
+ */
+ renderStatusIndicators() {
+ return this.createElement(
+ 'div',
+ { className: 'status-indicators' },
+ this.renderSignalBars(),
+ this.createElement(
+ 'span',
+ {
+ className: 'network-battery',
+ 'aria-label': 'Network: 5G, Battery: Full',
+ },
+ '5G',
+ this.renderBatteryIcon()
+ )
+ );
+ }
+
+ /**
+ * Render the status bar
+ * @returns {HTMLElement} The rendered status bar element
+ */
+ render() {
+ const { currentTime } = this.state;
+
+ return this.createElement(
+ 'div',
+ {
+ className: 'status-bar',
+ role: 'banner',
+ 'aria-label': 'Status bar',
+ },
+ this.createElement(
+ 'div',
+ {
+ className: 'status-left',
+ role: 'timer',
+ 'aria-label': 'Current time',
+ },
+ currentTime
+ ),
+ this.createElement('div', {
+ className: 'status-center',
+ 'aria-hidden': 'true',
+ }),
+ this.createElement('div', { className: 'status-right' }, this.renderStatusIndicators())
+ );
+ }
+}
+
+
+// ---- ../js/components/Modal.js ----
+/** @format */
+
+/**
+ * @class Modal
+ * @extends Component
+ * @description A reusable modal dialog component.
+ * Provides an overlay with a modal dialog box containing customizable content and actions.
+ * Supports keyboard interaction and click-outside-to-close behavior.
+ */
+class Modal extends Component {
+ /**
+ * @constructor
+ * @param {Object} props - Component properties
+ * @param {boolean} props.show - Whether the modal is visible
+ * @param {string} [props.title='Modal'] - Title of the modal
+ * @param {Array|Object} [props.children=[]] - Content to display in the modal
+ * @param {Function} [props.onClose] - Callback when modal is closed
+ * @param {Function} [props.onConfirm] - Callback when confirm button is clicked
+ */
+ constructor(props) {
+ super(props);
+
+ // Bind event handlers
+ this.handleOverlayClick = this.handleOverlayClick.bind(this);
+ this.handleModalClick = this.handleModalClick.bind(this);
+ this.handleKeyDown = this.handleKeyDown.bind(this);
+ }
+
+ /**
+ * Handle click events on the overlay
+ * @param {Event} e - Click event object
+ * @private
+ */
+ handleOverlayClick(e) {
+ if (e.target === e.currentTarget && this.props.onClose) {
+ this.props.onClose();
+ }
+ }
+
+ /**
+ * Prevent click events from bubbling through the modal
+ * @param {Event} e - Click event object
+ * @private
+ */
+ handleModalClick(e) {
+ e.stopPropagation();
+ }
+
+ /**
+ * Handle keyboard events for accessibility
+ * @param {KeyboardEvent} e - Keyboard event object
+ * @private
+ */
+ handleKeyDown(e) {
+ if (e.key === 'Escape' && this.props.onClose) {
+ this.props.onClose();
+ }
+ }
+
+ /**
+ * Render the modal actions (buttons)
+ * @param {Function} onClose - Close callback
+ * @param {Function} onConfirm - Confirm callback
+ * @param {string} confirmText - Text for confirm button
+ * @param {string} cancelText - Text for cancel button
+ * @returns {HTMLElement} The rendered actions element
+ * @private
+ */
+ renderActions(onClose, onConfirm, confirmText = 'Call', cancelText = 'Cancel', extraActions = [], hideCancel = false, hideConfirm = false) {
+ if (hideCancel && hideConfirm && !extraActions.length) {
+ return null;
+ }
+
+ return this.createElement(
+ 'div',
+ { className: 'modal-actions' },
+ hideCancel ? null : this.createElement(
+ 'button',
+ {
+ className: 'button secondary',
+ onClick: () => onClose?.(),
+ type: 'button',
+ 'aria-label': cancelText,
+ },
+ cancelText
+ ),
+ ...extraActions.map((action) => this.createElement(
+ 'button',
+ {
+ className: action.className || 'button secondary',
+ onClick: () => action.onClick?.(),
+ type: 'button',
+ disabled: action.disabled === true,
+ 'aria-label': action.ariaLabel || action.text,
+ },
+ action.text
+ )),
+ hideConfirm ? null : this.createElement(
+ 'button',
+ {
+ className: 'button',
+ onClick: () => onConfirm?.(),
+ type: 'button',
+ 'aria-label': confirmText,
+ },
+ confirmText
+ )
+ );
+ }
+
+ /**
+ * Render the modal
+ * @returns {HTMLElement} The rendered modal element
+ */
+ render() {
+ const { show, title, children = [], onClose, onConfirm, confirmText, cancelText, extraActions = [], hideCancel = false, hideConfirm = false } = this.props;
+
+ if (!show) {
+ return this.createElement('div', {
+ className: 'hidden',
+ style: { display: 'none' },
+ 'aria-hidden': 'true',
+ });
+ }
+
+ // Ensure children is always an array
+ const childElements = Array.isArray(children) ? children : [children];
+
+ return this.createElement(
+ 'div',
+ {
+ className: 'modal-overlay',
+ onClick: this.handleOverlayClick,
+ onKeyDown: this.handleKeyDown,
+ role: 'dialog',
+ 'aria-modal': 'true',
+ 'aria-labelledby': 'modal-title',
+ },
+ this.createElement(
+ 'div',
+ {
+ className: 'modal',
+ onClick: this.handleModalClick,
+ role: 'document',
+ tabIndex: -1,
+ },
+ this.createElement(
+ 'h2',
+ {
+ id: 'modal-title',
+ role: 'heading',
+ 'aria-level': '2',
+ },
+ title || 'Modal'
+ ),
+ this.createElement(
+ 'div',
+ {
+ className: 'modal-content',
+ role: 'region',
+ 'aria-label': 'Modal content',
+ },
+ ...childElements.filter((child) => child != null)
+ ),
+ this.renderActions(onClose, onConfirm, confirmText, cancelText, extraActions, hideCancel, hideConfirm)
+ )
+ );
+ }
+}
+
+
+// ---- ../js/components/NavigationBar.js ----
+/** @format */
+
+/**
+ * @class NavigationBar
+ * @extends Component
+ * @description A navigation bar component that provides app navigation controls.
+ * Handles back navigation and displays the current screen title.
+ */
+class NavigationBar extends Component {
+ /**
+ * @constructor
+ * @param {Object} props - Component properties
+ * @param {boolean} [props.showBackButton=false] - Whether to show the back button
+ * @param {string} [props.title] - Title to display in the navigation bar
+ * @param {Object|HTMLElement} [props.leftButton] - Optional custom button to display on the left side (overrides back button)
+ * @param {Object|HTMLElement} [props.rightButton] - Optional button to display on the right side
+ */
+ constructor(props) {
+ super(props);
+ this.handleBackClick = this.handleBackClick.bind(this);
+ this.handleKeyDown = this.handleKeyDown.bind(this);
+ }
+
+ /**
+ * Handle back button click event
+ * @private
+ */
+ handleBackClick() {
+ const currentState = globalState.getState();
+
+ // Priority 1: If we're in a conversation, go back to messages list
+ if (currentState.selectedConversation) {
+ globalState.setState({
+ selectedConversation: null,
+ selectedConversationRaw: null,
+ });
+ return; // Exit early, don't execute the rest
+ }
+
+ if (currentState.showMessageContactPicker) {
+ globalState.setState({
+ showMessageContactPicker: false,
+ });
+ return;
+ }
+
+ if (currentState.selectedEmail || currentState.showEmailComposer) {
+ globalState.setState({
+ selectedEmail: null,
+ showEmailComposer: false,
+ });
+ return;
+ }
+
+ // Priority 2: If we came from phone app, go back to phone
+ if (currentState.previousApp === 'phone') {
+ globalState.setState({
+ currentApp: 'phone',
+ previousApp: null,
+ });
+ return; // Exit early
+ }
+
+ // Priority 3: Default - go to home and clear everything
+ globalState.setState({
+ currentApp: 'home',
+ previousApp: null,
+ selectedConversation: null,
+ selectedConversationRaw: null,
+ selectedContact: null,
+ showMessageContactPicker: false,
+ showModal: false,
+ });
+ }
+
+ /**
+ * Handle keyboard events for accessibility
+ * @param {KeyboardEvent} e - Keyboard event object
+ * @private
+ */
+ handleKeyDown(e) {
+ if (e.key === 'Backspace' && this.props.showBackButton) {
+ this.handleBackClick();
+ }
+ }
+
+ /**
+ * Render the left section (custom button, back button, or spacer)
+ * @returns {HTMLElement} The rendered element
+ * @private
+ */
+ renderLeftSection() {
+ const { leftButton, showBackButton } = this.props;
+
+ // Priority 1: Custom left button
+ if (leftButton) {
+ if (leftButton instanceof HTMLElement) {
+ return leftButton;
+ }
+
+ return this.createElement(
+ leftButton.element || 'button',
+ leftButton.props || {},
+ leftButton.content
+ );
+ }
+
+ // Priority 2: Default back button
+ if (showBackButton) {
+ return this.createElement(
+ 'button',
+ {
+ className: 'nav-back-button',
+ onClick: this.handleBackClick,
+ 'aria-label': 'Go back',
+ type: 'button',
+ },
+ this.createElement('img', {
+ src: 'data:image/svg+xml;utf8,',
+ alt: '',
+ style: 'width:24px;height:24px;padding:0;margin:0;display:block;pointer-events:none;'
+ })
+ );
+ }
+
+ // Priority 3: Empty spacer
+ return this.createElement('div', {
+ className: 'nav-spacer',
+ 'aria-hidden': 'true',
+ });
+ }
+
+ /**
+ * Render the right button section
+ * @returns {HTMLElement} The rendered element
+ * @private
+ */
+ renderRightSection() {
+ const { rightButton } = this.props;
+
+ if (!rightButton) {
+ return this.createElement('div', {
+ className: 'nav-spacer',
+ 'aria-hidden': 'true',
+ });
+ }
+
+ if (rightButton instanceof HTMLElement) {
+ return rightButton;
+ }
+
+ return this.createElement(
+ rightButton.element || 'button',
+ rightButton.props || {},
+ rightButton.content
+ );
+ }
+
+ /**
+ * Render the navigation bar
+ * @returns {HTMLElement} The rendered navigation bar element
+ */
+ render() {
+ const { title } = this.props;
+
+ return this.createElement(
+ 'nav',
+ {
+ className: 'navigation-bar',
+ role: 'navigation',
+ 'aria-label': 'Main navigation',
+ onKeyDown: this.handleKeyDown,
+ },
+ this.renderLeftSection(),
+ title &&
+ this.createElement(
+ 'h1',
+ {
+ className: 'nav-title',
+ role: 'heading',
+ 'aria-level': '1',
+ },
+ title
+ ),
+ this.renderRightSection()
+ );
+ }
+}
+
+
+// ---- ../js/components/HomeIndicator.js ----
+/** @format */
+
+/**
+ * @class HomeIndicator
+ * @extends Component
+ * @description A component that renders the iPhone-style home indicator.
+ * Provides navigation back to the home screen via click or swipe gestures.
+ * Currently implements click handling, with swipe gesture support planned for future.
+ */
+class HomeIndicator extends Component {
+ /**
+ * @constructor
+ * @param {Object} props - Component properties
+ */
+ constructor(props) {
+ super(props);
+
+ // Bind event handlers
+ this.handleClick = this.handleClick.bind(this);
+ this.handleSwipeUp = this.handleSwipeUp.bind(this);
+
+ // Touch event state for future swipe implementation
+ this.touchStartY = 0;
+ }
+
+ /**
+ * Resets the app state and navigates to home screen
+ * @private
+ */
+ handleClick() {
+ globalState.setState({
+ currentApp: 'home',
+ selectedConversation: null,
+ selectedConversationRaw: null,
+ selectedContact: null,
+ showMessageContactPicker: false,
+ showModal: false,
+ });
+ }
+
+ /**
+ * Handles swipe up gesture
+ * @param {Event} e - Touch/swipe event object
+ * @private
+ * @todo Implement proper swipe gesture detection
+ */
+ handleSwipeUp(e) {
+ // Simple click handler for now, swipe gesture to be implemented
+ this.handleClick();
+ }
+
+ /**
+ * Render the home indicator
+ * @returns {HTMLElement} The rendered home indicator element
+ */
+ render() {
+ return this.createElement(
+ 'div',
+ {
+ className: 'home-indicator-container',
+ onClick: this.handleClick,
+ role: 'button',
+ 'aria-label': 'Return to home screen',
+ tabIndex: 0,
+ onKeyPress: (e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ this.handleClick();
+ }
+ },
+ },
+ this.createElement('div', {
+ className: 'home-indicator',
+ 'aria-hidden': 'true',
+ })
+ );
+ }
+}
+
+
+// ---- ../js/components/SearchBar.js ----
+/** @format */
+
+/**
+ * @class SearchBar
+ * @extends Component
+ * @description A search input component that provides debounced search functionality.
+ * Includes built-in debouncing to prevent excessive search updates.
+ */
+class SearchBar extends Component {
+ /**
+ * @constructor
+ * @param {Object} props - Component properties
+ * @param {string} [props.placeholder='Search contacts...'] - Placeholder text for the search input
+ * @param {Function} [props.onSearch] - Callback function when search value changes
+ * @param {string} [props.value] - Initial input value
+ */
+ constructor(props) {
+ super(props);
+
+ // Set debounce delay
+ this.DEBOUNCE_DELAY = 300; // milliseconds
+
+ // Initialize state
+ this.state = {
+ searchTerm: props.value || ''
+ };
+
+ // Bind methods
+ this.handleInput = debounce(this.handleInput.bind(this), this.DEBOUNCE_DELAY);
+ this.handleInputChange = this.handleInputChange.bind(this);
+ this.handleKeyDown = this.handleKeyDown.bind(this);
+ }
+
+ /**
+ * Update state when props change
+ * @param {Object} nextProps - Next props
+ */
+ componentWillReceiveProps(nextProps) {
+ if (nextProps.value !== this.props.value) {
+ this.setState({ searchTerm: nextProps.value });
+ }
+ }
+
+ /**
+ * Handle input change events
+ * @param {Event} e - Input change event
+ * @private
+ */
+ handleInputChange(e) {
+ const value = e.target.value;
+ this.setState({ searchTerm: value });
+ this.handleInput(value);
+ }
+
+ /**
+ * Debounced search handler
+ * @param {string} searchTerm - Current search term
+ * @private
+ */
+ handleInput(searchTerm) {
+ const { onSearch } = this.props;
+ if (onSearch) {
+ onSearch(searchTerm);
+ }
+ }
+
+ /**
+ * Handle keyboard events
+ * @param {KeyboardEvent} e - Keyboard event
+ * @private
+ */
+ handleKeyDown(e) {
+ // Clear search on Escape
+ if (e.key === 'Escape') {
+ this.setState({ searchTerm: '' });
+ this.handleInput('');
+ }
+ }
+
+ /**
+ * Render the search bar
+ * @returns {HTMLElement} The rendered search bar element
+ */
+ render() {
+ const { placeholder = 'Search contacts...' } = this.props;
+ const { searchTerm } = this.state;
+
+ return this.createElement(
+ 'div',
+ {
+ className: 'search-bar',
+ role: 'search',
+ 'aria-label': 'Search contacts',
+ style: {
+ paddingBottom: '10px',
+ borderBottom: '1px solid #e9ecef',
+ },
+ },
+ this.createElement('input', {
+ type: 'search',
+ placeholder,
+ value: searchTerm,
+ onInput: this.handleInputChange,
+ onKeyDown: this.handleKeyDown,
+ 'aria-label': placeholder,
+ style: {
+ width: '100%',
+ padding: '10px',
+ border: '1px solid #ddd',
+ borderRadius: '20px',
+ fontSize: '16px',
+ outline: 'none',
+ },
+ })
+ );
+ }
+}
+
+
+// ---- ../js/components/Header.js ----
+/** @format */
+
+/**
+ * @class Header
+ * @extends Component
+ * @description A component that renders a header section with a title.
+ * Used for displaying page or section titles in the phone UI.
+ */
+class Header extends Component {
+ /**
+ * @constructor
+ * @param {Object} props - Component properties
+ * @param {string} [props.title='Phone UI'] - The title text to display in the header
+ */
+ constructor(props) {
+ super(props);
+ }
+
+ /**
+ * Render the header component
+ * @returns {HTMLElement} The rendered header element
+ */
+ render() {
+ const { title = 'Phone UI' } = this.props;
+
+ return this.createElement(
+ 'header',
+ {
+ className: 'header',
+ role: 'banner',
+ 'aria-label': 'Page header',
+ },
+ this.createElement(
+ 'h1',
+ {
+ role: 'heading',
+ 'aria-level': '1',
+ },
+ title
+ )
+ );
+ }
+}
+
+
+// ---- ../js/components/HomeScreen.js ----
+/** @format */
+
+/**
+ * @class HomeScreen
+ * @extends Component
+ * @description The main home screen component that displays the app grid.
+ * Manages the display and interaction of app icons, handling navigation to different apps.
+ */
+class HomeScreen extends Component {
+ /**
+ * Cache for loaded icons
+ * @static
+ * @private
+ */
+ static iconCache = new Map();
+
+ /**
+ * @constructor
+ * @param {Object} props - Component properties
+ */
+ constructor(props) {
+ super(props);
+ this.handleAppClick = this.handleAppClick.bind(this);
+ this.state = {
+ isDarkTheme: document.documentElement.getAttribute('data-theme') === 'dark'
+ };
+ }
+
+ static iconPath(app, isDarkTheme) {
+ return PhoneMedia.base64Path('images', isDarkTheme ? 'dark' : 'light', `${app.icon}.png`);
+ }
+
+ static backgroundPath(isDarkTheme) {
+ return PhoneMedia.base64Path('images', 'bg', isDarkTheme ? 'bgdark_01_ca.png' : 'bglight_01_ca.png');
+ }
+
+ componentDidMount() {
+ // Initial background update
+ this.updateBackground();
+
+ // Listen for theme changes
+ document.addEventListener('themeChanged', (event) => {
+ const isDarkTheme = event.detail.theme === 'dark';
+
+ // Update background immediately
+ const bgPath = HomeScreen.backgroundPath(isDarkTheme);
+
+ PhoneMedia.loadImage(bgPath).then(imageContent => {
+ if (this.element) {
+ this.element.style.background = `url('${imageContent}')`;
+ this.element.style.backgroundSize = 'contain';
+ this.element.style.backgroundPosition = 'center';
+ }
+ }).catch(error => {
+ console.error(`Failed to load background image: ${bgPath}`, error);
+ });
+
+ // Update state after background change
+ this.setState({ isDarkTheme });
+ });
+ }
+
+ updateBackground() {
+ const isDarkTheme = document.documentElement.getAttribute('data-theme') === 'dark';
+ const bgPath = HomeScreen.backgroundPath(isDarkTheme);
+
+ PhoneMedia.loadImage(bgPath).then(imageContent => {
+ if (this.element) {
+ this.element.style.background = `url('${imageContent}')`;
+ this.element.style.backgroundSize = 'contain';
+ this.element.style.backgroundPosition = 'center';
+ this.element.style.backgroundRepeat = 'no-repeat';
+ this.element.style.backgroundColor = isDarkTheme ? '#000000' : '#ffffff';
+ } else {
+ console.error('HomeScreen element not found during background update');
+ }
+ }).catch(error => {
+ console.error(`Failed to load background image: ${bgPath}`, error);
+ });
+ }
+
+ /**
+ * List of available apps with their configurations
+ * @type {Array}
+ * @private
+ */
+ static get apps() {
+ return [
+ { name: 'safari', title: 'Safari', icon: 'Safari', color: '' },
+ { name: 'mail', title: 'Mail', icon: 'Mail', color: '' },
+ { name: 'notes', title: 'Notes', icon: 'Notes', color: '' },
+ { name: 'iCloud', title: 'iCloud', icon: 'iCloud', color: '' },
+ { name: 'camera', title: 'Camera', icon: 'Camera', color: '' },
+ { name: 'photos', title: 'Photos', icon: 'Photos', color: '' },
+ { name: 'clock', title: 'Clock', icon: 'Clock', color: '' },
+ { name: 'calendar', title: 'Calendar', icon: 'Calendar', color: '' },
+ { name: 'store', title: 'App Store', icon: 'AppStore', color: '' },
+ ];
+ }
+
+ /**
+ * List of apps to show in the dock
+ * @type {Array}
+ * @private
+ */
+ static get dockApps() {
+ return [
+ { name: 'phone', title: '', icon: 'Phone', color: '' },
+ { name: 'contacts', title: '', icon: 'Contacts', color: '' },
+ { name: 'messages', title: '', icon: 'Message', color: '' },
+ { name: 'settings', title: '', icon: 'Settings', color: '' },
+ ];
+ }
+
+ /**
+ * Handles app icon click events
+ * @param {string} appName - Name of the clicked app
+ * @private
+ */
+ handleAppClick(appName) {
+ globalState.setState({ currentApp: appName });
+ }
+
+ /**
+ * Renders an individual app icon
+ * @param {AppConfig} app - App configuration object
+ * @returns {HTMLElement} The rendered app icon element
+ * @private
+ */
+ renderAppIcon(app) {
+ const imgElement = this.createElement('img', {
+ alt: app.title,
+ style: { display: 'none' } // Hide initially
+ });
+
+ const isDarkTheme = document.documentElement.getAttribute('data-theme') === 'dark';
+ const iconPath = HomeScreen.iconPath(app, isDarkTheme);
+
+ // Check cache first
+ if (HomeScreen.iconCache.has(iconPath)) {
+ imgElement.src = HomeScreen.iconCache.get(iconPath);
+ imgElement.style.display = 'block';
+ } else {
+ // Load the file if not in cache
+ PhoneMedia.loadImage(iconPath).then(imageContent => {
+ HomeScreen.iconCache.set(iconPath, imageContent);
+ imgElement.src = imageContent;
+ imgElement.style.display = 'block';
+ }).catch(error => {
+ console.error(`Failed to load icon for ${app.title}:`, error);
+ });
+ }
+
+ return this.createElement(
+ 'div',
+ {
+ className: 'app-icon',
+ onClick: () => this.handleAppClick(app.name),
+ role: 'button',
+ 'aria-label': `Open ${app.title} app`,
+ tabIndex: 0,
+ onKeyPress: (e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ this.handleAppClick(app.name);
+ }
+ },
+ },
+ this.createElement(
+ 'div',
+ {
+ className: 'app-icon-symbol',
+ 'aria-hidden': 'true',
+ style: app.color ? { background: app.color } : {}
+ },
+ imgElement
+ ),
+ this.createElement('span', { className: 'app-title' }, app.title)
+ );
+ }
+
+ /**
+ * Render the home screen
+ * @returns {HTMLElement} The rendered home screen element
+ */
+ render() {
+ return this.createElement(
+ 'div',
+ {
+ className: 'home-screen',
+ role: 'main',
+ 'aria-label': 'Home screen',
+ },
+ this.createElement(
+ 'div',
+ {
+ className: 'app-grid',
+ role: 'grid',
+ 'aria-label': 'App grid',
+ },
+ ...HomeScreen.apps.map((app) => this.renderAppIcon(app))
+ ),
+ this.createElement(
+ 'div',
+ {
+ className: 'dock',
+ role: 'toolbar',
+ 'aria-label': 'App dock',
+ },
+ ...HomeScreen.dockApps.map((app) => this.renderAppIcon(app))
+ )
+ );
+ }
+}
+
+/**
+ * @typedef {Object} AppConfig
+ * @property {string} name - Internal name/identifier of the app
+ * @property {string} title - Display title of the app
+ * @property {string} icon - Emoji icon representing the app
+ * @property {string} color - Background color for the app icon (if any)
+ */
+
+
+// ---- ../js/apps/phone/components/Dialpad.js ----
+/**
+ * @format
+ * @class Dialpad
+ * @extends Component
+ * @description A phone dialpad component providing a touch-tone keypad interface for making calls. Manages phone number input, formatting, call state, and integration with contacts.
+ */
+
+class Dialpad extends Component {
+ static fieldCommanderPhoneNumber = '0160000000';
+
+ static assetPath(...parts) {
+ return PhoneMedia.base64Path('images', ...parts);
+ }
+
+ /**
+ * @constructor
+ * @param {Object} props - Component properties
+ */
+ constructor(props = {}) {
+ super(props);
+
+ this.state = {
+ phoneNumber: '', // Current phone number in the dialpad
+ isCallActive: false, // Whether a call is currently in progress
+ callDuration: 0, // Duration of active call in seconds
+ };
+
+ // Bind event handlers
+ this.handleNumberClick = this.handleNumberClick.bind(this);
+ this.handleCall = this.handleCall.bind(this);
+ this.handleEndCall = this.handleEndCall.bind(this);
+ this.handleDelete = this.handleDelete.bind(this);
+ this.handleOpenContacts = this.handleOpenContacts.bind(this);
+ this.handleGlobalStateChange = this.handleGlobalStateChange.bind(this);
+
+ this.callTimer = null;
+
+ // Subscribe to global state changes
+ globalState.subscribe(this.handleGlobalStateChange);
+ }
+
+ // -------------------------------------------------------------------------
+ // Lifecycle Methods
+ // -------------------------------------------------------------------------
+
+ /**
+ * @method componentDidMount
+ * @description Initializes component after mounting, handling any existing phone number in global state
+ */
+ componentDidMount() {
+ const state = globalState.getState();
+ if (state.phoneNumber) {
+ this.setState(
+ {
+ phoneNumber: this.cleanPhoneNumber(state.phoneNumber),
+ },
+ () => {
+ globalState.setState({ phoneNumber: '' });
+ }
+ );
+ }
+ }
+
+ /**
+ * @method componentWillUnmount
+ * @description Cleanup resources and subscriptions when component unmounts
+ */
+ componentWillUnmount() {
+ if (this.callTimer) {
+ clearInterval(this.callTimer);
+ }
+ globalState.unsubscribe(this.handleGlobalStateChange);
+ }
+
+ // -------------------------------------------------------------------------
+ // Phone Number Utilities
+ // -------------------------------------------------------------------------
+
+ /**
+ * @method cleanPhoneNumber
+ * @description Removes all non-digit characters from a phone number
+ * @param {string} number - The phone number to clean
+ * @returns {string} The cleaned phone number containing only digits
+ */
+ cleanPhoneNumber(number) {
+ if (!number) return '';
+ return number.replace(/\D/g, '');
+ }
+
+ /**
+ * @method formatPhoneNumber
+ * @description Formats a phone number into a readable format
+ * @param {string} number - The phone number to format
+ * @returns {string} Formatted phone number as (XXX) XXX-XXXX
+ */
+ formatPhoneNumber(number) {
+ if (!number || number.length === 0) return '';
+
+ const cleaned = number.replace(/[^\d]/g, '');
+
+ if (cleaned.length <= 3) {
+ return cleaned;
+ } else if (cleaned.length <= 6) {
+ return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3)}`;
+ } else if (cleaned.length <= 10) {
+ return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`;
+ } else {
+ return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6, 10)}`;
+ }
+ }
+
+ /**
+ * @method formatTime
+ * @description Formats seconds into MM:SS format
+ * @param {number} seconds - Number of seconds to format
+ * @returns {string} Time formatted as MM:SS
+ */
+ formatTime(seconds) {
+ const mins = Math.floor(seconds / 60);
+ const secs = seconds % 60;
+ return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
+ }
+
+ // -------------------------------------------------------------------------
+ // Event Handlers
+ // -------------------------------------------------------------------------
+
+ /**
+ * @method handleGlobalStateChange
+ * @description Handles changes in the global state, specifically phone number updates
+ * @param {Object} newState - The new global state
+ */
+ handleGlobalStateChange(newState) {
+ if (newState.phoneNumber) {
+ const cleaned = this.cleanPhoneNumber(newState.phoneNumber);
+ if (cleaned && cleaned !== this.state.phoneNumber) {
+ this.setState(
+ {
+ phoneNumber: cleaned,
+ },
+ () => {
+ globalState.setState({ phoneNumber: '' });
+ }
+ );
+ }
+ }
+ }
+
+ /**
+ * @method handleNumberClick
+ * @description Handles digit button clicks on the dialpad
+ * @param {string} number - The digit that was clicked
+ */
+ handleNumberClick(number) {
+ if (!this.state.isCallActive) {
+ this.setState({
+ phoneNumber: this.state.phoneNumber + number,
+ });
+ }
+ }
+
+ /**
+ * @method handleDelete
+ * @description Handles the delete button click, removing the last digit
+ */
+ handleDelete() {
+ if (!this.state.isCallActive) {
+ this.setState({
+ phoneNumber: this.state.phoneNumber.slice(0, -1),
+ });
+ }
+ }
+
+ /**
+ * @method handleCall
+ * @description Initiates a phone call and starts the call timer
+ */
+ handleCall() {
+ if (
+ this.state.phoneNumber &&
+ !this.state.isCallActive &&
+ this.cleanPhoneNumber(this.state.phoneNumber) !== Dialpad.fieldCommanderPhoneNumber
+ ) {
+ this.setState({
+ isCallActive: true,
+ callDuration: 0,
+ });
+
+ this.callTimer = setInterval(() => {
+ // Update state directly to avoid re-render during call
+ this.state.callDuration = this.state.callDuration + 1;
+
+ // Update only the call duration display element
+ const durationElement = document.querySelector('.call-duration');
+ if (durationElement) {
+ durationElement.textContent = this.formatTime(this.state.callDuration);
+ }
+ }, 1000);
+ }
+ }
+
+ /**
+ * @method handleEndCall
+ * @description Ends the current call and resets the dialpad state
+ */
+ handleEndCall() {
+ if (this.callTimer) {
+ clearInterval(this.callTimer);
+ this.callTimer = null;
+ }
+
+ this.setState({
+ isCallActive: false,
+ callDuration: 0,
+ phoneNumber: '',
+ });
+ }
+
+ /**
+ * @method handleOpenContacts
+ * @description Navigates to the contacts view
+ */
+ handleOpenContacts() {
+ globalState.setState({
+ currentApp: 'contacts',
+ previousApp: 'phone',
+ });
+ }
+
+ // -------------------------------------------------------------------------
+ // Render Methods
+ // -------------------------------------------------------------------------
+
+ /**
+ * @method render
+ * @description Renders the phone dialpad interface
+ * @returns {Object} Virtual DOM representation of the component
+ */
+ render() {
+ const { phoneNumber, isCallActive, callDuration } = this.state;
+ const isPhoneNumberEmpty = phoneNumber.length === 0;
+
+ const dialpadNumbers = [
+ ['1', ''],
+ ['2', 'ABC'],
+ ['3', 'DEF'],
+ ['4', 'GHI'],
+ ['5', 'JKL'],
+ ['6', 'MNO'],
+ ['7', 'PQRS'],
+ ['8', 'TUV'],
+ ['9', 'WXYZ'],
+ ['*', ''],
+ ['0', '+'],
+ ['#', ''],
+ ];
+
+ if (isCallActive) {
+ return this.createElement(
+ 'div',
+ {
+ className: 'phone-dialpad call-active',
+ role: 'region',
+ 'aria-label': 'Active call interface',
+ },
+ this.createElement(
+ 'div',
+ {
+ className: 'call-info',
+ role: 'status',
+ 'aria-live': 'polite',
+ },
+ this.createElement('div', { className: 'call-status' }, 'Calling...'),
+ this.createElement('div', { className: 'call-number' }, this.formatPhoneNumber(phoneNumber)),
+ this.createElement('div', { className: 'call-duration' }, this.formatTime(callDuration))
+ ),
+ this.createElement(
+ 'div',
+ { className: 'call-actions' },
+ this.createElement(
+ 'button',
+ {
+ className: 'end-call-btn',
+ onClick: this.handleEndCall,
+ 'aria-label': 'End call',
+ },
+ (() => {
+ const imgElement = this.createElement('img', {
+ alt: 'End call',
+ style: { display: 'none' }
+ });
+
+ PhoneMedia.loadImage(Dialpad.assetPath('light', 'HangUp.png')).then(imageContent => {
+ imgElement.src = imageContent;
+ imgElement.style.display = 'block';
+ }).catch(error => {
+ console.error('Failed to load hang up icon:', error);
+ });
+
+ return imgElement;
+ })()
+ )
+ )
+ );
+ }
+
+ const callButtonProps = {
+ className: 'action-btn call-btn',
+ onClick: this.handleCall,
+ 'aria-label': 'Make call',
+ };
+
+ if (isPhoneNumberEmpty || this.cleanPhoneNumber(phoneNumber) === Dialpad.fieldCommanderPhoneNumber) {
+ callButtonProps.disabled = true;
+ }
+
+ return this.createElement(
+ 'div',
+ {
+ className: 'phone-dialpad',
+ role: 'region',
+ 'aria-label': 'Phone dialer',
+ },
+ this.createElement(
+ 'div',
+ {
+ className: 'phone-display',
+ role: 'textbox',
+ 'aria-label': 'Phone number display',
+ },
+ this.createElement('div', { className: 'phone-number' }, this.formatPhoneNumber(phoneNumber) || 'Enter a number')
+ ),
+ this.createElement(
+ 'div',
+ {
+ className: 'dialpad',
+ role: 'grid',
+ 'aria-label': 'Dial pad',
+ },
+ ...dialpadNumbers.map(([number, letters]) =>
+ this.createElement(
+ 'button',
+ {
+ className: 'dialpad-btn',
+ onClick: () => this.handleNumberClick(number),
+ 'aria-label': `Dial ${number}${letters ? ` (${letters})` : ''}`,
+ },
+ this.createElement('span', { className: 'number' }, number),
+ letters && this.createElement('span', { className: 'letters' }, letters)
+ )
+ )
+ ),
+ this.createElement(
+ 'div',
+ {
+ className: 'phone-actions',
+ role: 'toolbar',
+ 'aria-label': 'Phone actions',
+ },
+ this.createElement(
+ 'button',
+ {
+ className: 'action-btn delete-btn',
+ onClick: this.handleDelete,
+ 'aria-label': 'Delete last digit',
+ },
+ this.createElement('img', {
+ src: 'data:image/svg+xml;utf8,',
+ alt: 'Delete',
+ style: 'width:28px;height:28px;padding:0;margin:4px 4px 0 0;display:block;pointer-events:none;'
+ })
+ ),
+ this.createElement('button', callButtonProps,
+ (() => {
+ const imgElement = this.createElement('img', {
+ alt: 'Make call',
+ style: { display: 'none' }
+ });
+
+ PhoneMedia.loadImage(Dialpad.assetPath('light', 'Call.png')).then(imageContent => {
+ imgElement.src = imageContent;
+ imgElement.style.display = 'block';
+ }).catch(error => {
+ console.error('Failed to load call icon:', error);
+ });
+
+ return imgElement;
+ })()
+ ),
+ this.createElement(
+ 'button',
+ {
+ className: 'action-btn contact-btn',
+ onClick: this.handleOpenContacts,
+ 'aria-label': 'Open contacts',
+ },
+ (() => {
+ const imgElement = this.createElement('img', {
+ alt: 'Open contacts',
+ style: { display: 'none' }
+ });
+
+ PhoneMedia.loadImage(Dialpad.assetPath('light', 'Contact.png')).then(imageContent => {
+ imgElement.src = imageContent;
+ imgElement.style.display = 'block';
+ }).catch(error => {
+ console.error('Failed to load contact icon:', error);
+ });
+
+ return imgElement;
+ })()
+ )
+ )
+ );
+ }
+}
+
+
+// ---- ../js/apps/phone/index.js ----
+/**
+ * @fileoverview Main entry point for the Phone application
+ *
+ * This module initializes the Phone app UI, including:
+ * - Rendering the dialpad component
+ * - Mounting the dialpad into the provided container
+ *
+ * The initializePhoneApp function is exposed globally for use by the main app.
+ */
+
+// Initialize the phone app
+function initializePhoneApp(container) {
+ // Create and mount the dialpad component
+ const phoneDialpad = new Dialpad();
+ phoneDialpad.mount(container);
+}
+
+// Make initialization function globally available
+window.initializePhoneApp = initializePhoneApp;
+
+// ---- ../js/apps/messages/components/MessagesList.js ----
+/** @format */
+
+/**
+ * @class MessagesList
+ * @extends Component
+ * @description A component that renders a list of message items.
+ * Manages the display of MessageItem components and handles message selection.
+ */
+class MessagesList extends Component {
+ /**
+ * @constructor
+ * @param {Object} props - Component properties
+ * @param {Array