Merge pull request 'Remove Redis backend support' (#4) from feature/surrealdb-storage into master

Reviewed-on: #4
This commit is contained in:
Jacob Schmidt 2026-04-17 17:29:34 -05:00
commit b620393eae
272 changed files with 29868 additions and 6441 deletions

View File

@ -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<br/>- loadout<br/>- position<br/>- stats]
end
ClientA --- OptimisticCache
ClientB --- OptimisticCache
ClientN --- OptimisticCache
end
subgraph Server [ArmA 3 SERVER #40;Hot Cache#41;]
Registry["GVAR(Registry)<br/>In-Memory HashMap<br/>UID -> {loadout, position, stats...}"]
SessionMgmt[Session Management<br/>- Token Generation<br/>- UID Resolution<br/>- Player State]
end
subgraph Rust [EXTENSION #40;Cold Storage#41;]
ConnPool["Connection Pool<br/>(bb8-redis)<br/>2-10 connections"]
RedisOps[Redis Operations<br/>- actor_get/set/update<br/>- Async I/O]
end
subgraph Redis [DATABASE #40;Saved to Disc#41;]
ActorDataStore[Actor Data Store<br/>actor:UID -> JSON]
Modules[Additional Modules<br/>garage, locker, bank, org]
end
Clients -->|Event Driven<br/>#40;CBA A3 Events#41;| Server
Server -->|Extension Calls<br/>#40;Rust FFI#41;| Rust
Rust -->|Redis Protocol<br/>#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<br/>#40;Generated on server#41;]
Token --> UID[UID Resolution<br/>#40;Steam UID mapping#41;]
UID --> State[Player State Tracking<br/>#40;Tracked in Registry#41;]
State --> Access[Data Access Authorized<br/>#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
```

View File

@ -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"] }

345
README.md
View File

@ -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<br/>ArmA 3 Interface <---> Rust]
Services[Services Layer<br/>#40;Business Logic#41;]
Repositories[Repositories Layer<br/>#40;Data Persistence#41;]
Models[Models Layer<br/>#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.

View File

@ -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]; };

View File

@ -0,0 +1 @@
forge\forge_client\addons\phone

View File

@ -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));
};
};

View File

@ -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.

View File

@ -0,0 +1,3 @@
PREP(handleUIEvents);
PREP(initClass);
PREP(openUI);

View File

@ -0,0 +1 @@
#include "script_component.hpp"

View File

@ -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);

View File

@ -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"

View File

@ -0,0 +1 @@
#include "script_component.hpp"

View File

@ -0,0 +1,2 @@
#include "script_component.hpp"
#include "XEH_PREP.hpp"

View File

@ -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"

View File

@ -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;

View File

@ -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)

View File

@ -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;

View File

@ -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);

View File

@ -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"

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<Project name="FFE">
<Package name="Phone">
<Key ID="STR_forge_client_phone_displayName">
<English>Phone</English>
</Key>
<Key ID="STR_forge_client_phone_phone">
<English>Phone</English>
</Key>
<Key ID="STR_forge_client_phone_phoneTooltip">
<English>Open your phone</English>
</Key>
</Package>
</Project>

View File

@ -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;

View File

@ -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};
};
};
};

View File

@ -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.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script>
Promise.all([
// Load CSS file
A3API.RequestFile("forge\\forge_client\\addons\\phone\\ui\\_site\\dist\\app.bundle.css"),
// Load JavaScript file
A3API.RequestFile("forge\\forge_client\\addons\\phone\\ui\\_site\\dist\\app.bundle.js")
]).then(([css, js]) => {
// Apply CSS
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
// Load and execute JavaScript
const script = document.createElement('script');
script.text = js;
document.head.appendChild(script);
// Initialize the phone interface
initializeApp();
});
</script>
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@ -0,0 +1,324 @@
/**
* @fileoverview Root application component and integration logic.
*
* The App class manages:
* - Switching between different app modules (home, phone, messages, contacts, settings)
* - Rendering the correct app UI based on global state
* - Handling global modals (e.g., call confirmation)
* - Integrating shared UI elements (status bar, home indicator, dynamic island)
*
* Each app module is initialized via its global function (e.g., window.initializePhoneApp) and mounted into the app container.
* The placeholder app view is shown for unimplemented apps.
*
* This is the main entry point for the phone UI framework.
*/
/**
* @class App
* @extends Component
* @description The root component that manages app switching and integration
*/
class App extends Component {
/**
* @constructor
* Initializes state and subscribes to global state changes.
*/
constructor(props = {}) {
super(props);
this.state = {
...globalState.getState(),
currentApp: 'home',
showAddContactForm: false
};
this.unsubscribe = null;
}
/**
* Subscribe to global state changes after mounting
* @lifecycle
*/
componentDidMount() {
this.unsubscribe = globalState.subscribe((newState) => {
this.setState(newState);
});
}
/**
* Clean up subscriptions before unmounting
* @lifecycle
*/
componentWillUnmount() {
if (this.unsubscribe) {
this.unsubscribe();
}
}
/**
* Render the current app based on app state
* @returns {HTMLElement} Current app view
* @private
*/
renderCurrentApp() {
const { currentApp } = this.state;
const appContainer = this.createElement('div', { className: 'app-container' });
switch (currentApp) {
case 'clock':
window.initializeClockApp(appContainer);
break;
case 'calendar':
window.initializeCalendarApp(appContainer);
break;
case 'home':
return new HomeScreen();
case 'phone':
window.initializePhoneApp(appContainer);
break;
case 'messages':
window.initializeMessagesApp(appContainer);
break;
case 'mail':
window.initializeMailApp(appContainer);
break;
case 'notes':
window.initializeNotesApp(appContainer);
break;
case 'contacts':
window.initializeContactsApp(appContainer);
break;
case 'settings':
window.initializeSettingsApp(appContainer);
break;
default:
return this.renderPlaceholderApp(currentApp);
}
return appContainer;
}
/**
* Render a placeholder for unimplemented apps
* @param {string} appName - App name
* @returns {HTMLElement} Placeholder app view
* @private
*/
renderPlaceholderApp(appName) {
const appIcons = {
calendar: '',
camera: '',
store: '',
mail: '',
icloud: '',
photos: '',
safari: ''
};
return this.createElement(
'div',
{ className: 'app-container' },
new NavigationBar({ title: appName }),
this.createElement(
'div',
{ className: 'content' },
this.createElement(
'div',
{
style: {
textAlign: 'center',
padding: '50px 20px',
color: '#6c757d',
},
},
this.createElement('h2', { role: 'img', 'aria-label': appName }, appIcons[appName] || ''),
this.createElement('p', {}, `${appName} app coming soon!`)
)
)
);
}
/**
* Render the phone app UI, including status bar, main content, home indicator, and modals.
* @returns {HTMLElement} The rendered phone app
*/
render() {
const { currentApp, selectedContact, showModal, showDeleteModal, noteToDelete, eventToDelete } = this.state;
const openMessageThread = (contact) => {
if (!contact || contact.canMessage === false) return;
const contactId = contact.contactId || contact.uid || contact.id;
if (!contactId) return;
const { messages = [], rawMessages = [], currentUid = window.__playerUid } = globalState.getState();
const existingConversation = messages.find((message) => (message.contactId || message.id) === contactId);
const selectedRawMessages = rawMessages.filter((message) =>
message &&
(
(message.from === currentUid && message.to === contactId) ||
(message.from === contactId && message.to === currentUid)
)
);
const conversation = existingConversation || {
...contact,
id: contactId,
contactId,
contactName: contact.fullName || contact.name || contactId,
conversation: [],
hasConversation: false
};
globalState.setState({
currentApp: 'messages',
selectedContact: null,
showModal: false,
showMessageContactPicker: false,
selectedConversation: {
...conversation,
id: contactId,
contactId,
contactName: conversation.contactName || contact.fullName || contact.name || contactId,
conversation: conversation.conversation || []
},
selectedConversationRaw: {
otherUid: contactId,
messages: selectedRawMessages
}
});
};
return this.createElement(
'div',
{
className: 'phone-container',
role: 'application',
'aria-label': 'Phone interface',
},
this.createElement(
'div',
{
className: 'phone-screen dynamic-island',
role: 'main',
},
// Dynamic Island content
this.createElement(
'div',
{
className: 'dynamic-island-content',
'aria-hidden': 'true',
},
this.createElement('div', { className: 'speaker' }),
this.createElement('div', { className: 'camera' })
),
// Status bar
new StatusBar(),
// Main app content
this.renderCurrentApp(),
// Home indicator (except on home screen)
currentApp !== 'home' && new HomeIndicator(),
// Call modal
showModal && selectedContact && new Modal({
show: showModal,
title: selectedContact.canCall === false ? (selectedContact.fullName || selectedContact.name) : `Call ${selectedContact.fullName || selectedContact.name}?`,
confirmText: selectedContact.canCall === false ? 'Close' : 'Call',
cancelText: selectedContact.canCall === false ? 'Back' : 'Cancel',
hideCancel: true,
hideConfirm: selectedContact.canCall === false,
extraActions: selectedContact.canMessage === false || !(selectedContact.contactId || selectedContact.uid || selectedContact.id) ? [] : [{
text: 'Text',
ariaLabel: `Text ${selectedContact.fullName || selectedContact.name}`,
className: 'button secondary',
onClick: () => openMessageThread(selectedContact)
}],
onClose: () => globalState.setState({ showModal: false, selectedContact: null }),
onConfirm: () => {
if (selectedContact.canCall === false) {
globalState.setState({ showModal: false, selectedContact: null });
return;
}
globalState.setState({
phoneNumber: selectedContact.phone,
showModal: false,
selectedContact: null,
currentApp: 'phone'
});
},
children: [
this.createElement(
'p',
{ role: 'alert' },
selectedContact.canCall === false
? `${selectedContact.fullName || selectedContact.name} is a command broadcast contact. Incoming messages and email are available, but direct calls are disabled.`
: `Do you want to call ${selectedContact.fullName || selectedContact.name} at ${selectedContact.phone}?`
)
]
}),
// Delete note confirmation modal
showDeleteModal && noteToDelete && new Modal({
show: showDeleteModal,
title: `Delete "${noteToDelete.title}"?`,
confirmText: 'Delete',
cancelText: 'Cancel',
onClose: () => globalState.setState({ showDeleteModal: false, noteToDelete: null }),
onConfirm: () => {
// Find the onDelete handler from the notes editor and call it
const currentState = globalState.getState();
const currentNotes = currentState.notes || [];
const updatedNotes = currentNotes.filter(n => n.id !== noteToDelete.id);
globalState.setState({
notes: updatedNotes,
currentNote: null,
showNoteEditor: false,
showDeleteModal: false,
noteToDelete: null
});
// Delete from server
if (typeof deleteNote === 'function') {
deleteNote(noteToDelete.id);
}
console.log('Note deleted:', noteToDelete.id);
},
children: [this.createElement('p', { role: 'alert' }, `Are you sure you want to delete this note? This action cannot be undone.`)]
}),
showDeleteModal && eventToDelete && new Modal({
show: showDeleteModal,
title: `Delete "${eventToDelete.title}"?`,
confirmText: 'Delete',
cancelText: 'Cancel',
onClose: () => globalState.setState({ showDeleteModal: false, eventToDelete: null }),
onConfirm: () => {
// Find the onDelete handler from the events editor and call it
const currentState = globalState.getState();
const currentEvents = currentState.events || [];
const updatedEvents = currentEvents.filter(n => n.id !== eventToDelete.id);
globalState.setState({
events: updatedEvents,
currentEvent: null,
showEventEditor: false,
showDeleteModal: false,
eventToDelete: null
});
// Delete from server
if (typeof deleteCalendarEvent === 'function') {
deleteCalendarEvent(eventToDelete.id);
}
console.log('Event deleted:', eventToDelete.id);
},
children: [this.createElement('p', { role: 'alert' }, `Are you sure you want to delete this event? This action cannot be undone.`)]
})
)
);
}
}

View File

@ -0,0 +1,191 @@
/**
* @format
* @fileoverview Calendar component for displaying and managing calendar events
*/
class Calendar extends Component {
constructor(props = {}) {
super(props);
let selectedDate = props.selectedDate;
if (!(selectedDate instanceof Date) || isNaN(selectedDate.getTime())) {
selectedDate = new Date();
}
this.state = {
currentDate: props.selectedDate || new Date(),
selectedDate: props.selectedDate || new Date(),
events: props.events || [],
};
this.onEventClick = props.onEventClick;
this.onDayClick = props.onDayClick;
this.handleDayClick = this.handleDayClick.bind(this);
this.handleEventClick = this.handleEventClick.bind(this);
}
/**
* Called when the component is first mounted to the DOM.
* Ensures the initial view is rendered.
*/
componentDidMount() {
this.render(); // Initial render after component is mounted
}
/**
* Called when the component's state or props change.
* Updates the component if necessary.
*/
componentDidUpdate(prevProps, prevState) {
// Re-render if selectedDate or events have changed significantly
if (
prevState.selectedDate.toDateString() !== this.state.selectedDate.toDateString() ||
JSON.stringify(prevState.events) !== JSON.stringify(this.state.events) ||
prevState.currentDate.toDateString() !== this.state.currentDate.toDateString()
) {
this.render();
}
}
render() {
const { currentDate } = this.state;
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
return this.createElement(
'div',
{ className: 'calendar-container' },
this.createElement('div', { className: 'calendar-header' }, this.createElement('div', { className: 'calendar-title' }, `${this.getMonthName(month)} ${year}`)),
this.createElement('div', { className: 'calendar-grid' }, this.renderWeekdays(), this.renderDays(year, month)),
this.createElement('div', { className: 'calendar-events' }, this.renderEvents())
);
}
renderWeekdays() {
const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
return weekdays.map((day) => this.createElement('div', { className: 'calendar-weekday' }, day));
}
renderDays(year, month) {
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const startingDay = firstDay.getDay();
const totalDays = lastDay.getDate();
let days = [];
// Previous month's days (empty placeholders or actual days if needed, currently empty for visual alignment)
for (let i = 0; i < startingDay; i++) {
days.push(this.createElement('div', { className: 'calendar-day other-month' }));
}
// Current month's days
for (let day = 1; day <= totalDays; day++) {
const date = new Date(year, month, day);
const isToday = this.isToday(date);
const isSelected = this.isSelected(date);
const hasEvents = this.hasEvents(date);
let classes = ['calendar-day'];
if (isToday) classes.push('today');
if (isSelected) classes.push('selected');
if (hasEvents) classes.push('has-events');
days.push(
this.createElement(
'div',
{
className: classes.join(' '),
'data-date': date.toISOString(),
onClick: () => this.handleDayClick(date),
},
day
)
);
}
// Next month's days (empty placeholders for visual alignment)
const remainingCells = 42 - days.length; // 42 = 6 rows * 7 days
for (let i = 0; i < remainingCells; i++) {
days.push(this.createElement('div', { className: 'calendar-day other-month' }));
}
return days;
}
renderEvents() {
const events = this.getEventsForDate(this.state.selectedDate);
if (!events || events.length === 0) {
return this.createElement('div', { className: 'no-events' }, 'No events for this day');
}
return events.map((event) =>
this.createElement(
'div',
{
className: 'event-item',
'data-event-id': event.id,
onClick: () => this.handleEventClick(event),
},
this.createElement('div', { className: 'event-dot' }),
this.createElement('div', { className: 'event-time' }, this.formatTime(event.startTime)),
this.createElement('div', { className: 'event-title' }, event.title)
)
);
}
handleDayClick(date) {
this.setState({ selectedDate: date });
if (this.onDayClick) {
this.onDayClick(date);
}
}
handleEventClick(event) {
if (this.onEventClick) {
this.onEventClick(event);
}
}
getEventsForDate(date) {
const dateKey = this.getDateKey(date);
return this.state.events.filter((event) => {
const eventStartDate = new Date(event.startTime);
return this.getDateKey(eventStartDate) === dateKey;
});
}
hasEvents(date) {
return this.getEventsForDate(date).length > 0;
}
getDateKey(date) {
return date.toISOString().split('T')[0];
}
isToday(date) {
const today = new Date();
return date.toDateString() === today.toDateString();
}
isSelected(date) {
return date.toDateString() === this.state.selectedDate.toDateString();
}
getMonthName(month) {
return new Date(2000, month, 1).toLocaleString('default', { month: 'long' });
}
formatTime(time) {
return new Date(time).toLocaleTimeString('default', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
});
}
}

View File

@ -0,0 +1,290 @@
/**
* @format
* @class EventEditor
* @extends Component
* @description A component for creating and editing calendar events.
*/
class EventEditor extends Component {
/**
* @constructor
* @param {Object} props - Component properties
* @param {Object} [props.event] - Existing event to edit
* @param {Function} props.onSave - Callback when event is saved
* @param {Function} props.onCancel - Callback when editing is cancelled
* @param {Function} [props.onDelete] - Callback when event is deleted
*/
constructor(props = {}) {
super(props);
const existingEvent = props.event || {
title: '',
startTime: new Date(),
endTime: new Date(new Date().getTime() + 60 * 60 * 1000),
description: '',
};
this.state = {
title: existingEvent.title || '',
startTime: this.formatDateTimeForInput(existingEvent.startTime),
endTime: this.formatDateTimeForInput(existingEvent.endTime),
description: existingEvent.description || '',
id: existingEvent.id || null,
isModified: false,
};
// References for DOM elements
this.titleInputRef = null;
this.startTimeInputRef = null;
this.endTimeInputRef = null;
this.descriptionInputRef = null;
// Bind methods
this.handleTitleChange = this.handleTitleChange.bind(this);
this.handleStartTimeChange = this.handleStartTimeChange.bind(this);
this.handleEndTimeChange = this.handleEndTimeChange.bind(this);
this.handleDescriptionChange = this.handleDescriptionChange.bind(this);
this.handleSave = this.handleSave.bind(this);
this.handleCancel = this.handleCancel.bind(this);
this.handleDelete = this.handleDelete.bind(this);
this.setTitleInputRef = this.setTitleInputRef.bind(this);
this.setStartTimeInputRef = this.setStartTimeInputRef.bind(this);
this.setEndTimeInputRef = this.setEndTimeInputRef.bind(this);
this.setDescriptionInputRef = this.setDescriptionInputRef.bind(this);
}
/**
* Component mounted - focus on title if new event
*/
componentDidMount() {
if (!this.state.id && this.titleInputRef) {
this.titleInputRef.focus();
}
}
// Ref setter methods
setTitleInputRef(element) {
if (element) {
this.titleInputRef = element;
if (this.state.title && element.value !== this.state.title) {
element.value = this.state.title;
}
}
}
setStartTimeInputRef(element) {
if (element) {
this.startTimeInputRef = element;
if (this.state.startTime && element.value !== this.state.startTime) {
element.value = this.state.startTime;
}
}
}
setEndTimeInputRef(element) {
if (element) {
this.endTimeInputRef = element;
if (this.state.endTime && element.value !== this.state.endTime) {
element.value = this.state.endTime;
}
}
}
setDescriptionInputRef(element) {
if (element) {
this.descriptionInputRef = element;
if (this.state.description && element.value !== this.state.description) {
element.value = this.state.description;
}
}
}
// Input change handlers
handleTitleChange(e) {
this.state.title = e.target.value;
this.state.isModified = true;
}
handleStartTimeChange(e) {
this.state.startTime = e.target.value;
this.state.isModified = true;
}
handleEndTimeChange(e) {
this.state.endTime = e.target.value;
this.state.isModified = true;
}
handleDescriptionChange(e) {
this.state.description = e.target.value;
this.state.isModified = true;
}
handleSave() {
const { title, startTime, endTime, description, id } = this.state;
// if (!title.trim() || !startTime || !endTime) {
// alert('Please fill in all required fields.');
// return;
// }
const savedEvent = {
id: id || generateId(),
title: title.trim(),
startTime: new Date(startTime),
endTime: new Date(endTime),
description: description.trim(),
};
this.setState({
isModified: false,
id: savedEvent.id,
});
if (this.props.onSave) {
this.props.onSave(savedEvent);
}
}
handleCancel() {
if (this.props.onCancel) {
this.props.onCancel();
}
}
handleDelete() {
if (!this.state.id) {
console.warn('Cannot delete event: no ID present');
return;
}
if (!this.props.onDelete) {
console.warn('Cannot delete event: no onDelete callback provided');
return;
}
try {
// Show delete confirmation modal using global state
globalState.setState({
showDeleteModal: true,
eventToDelete: {
id: this.state.id,
title: this.state.title || 'Untitled',
},
});
} catch (error) {
console.error('Error showing delete confirmation:', error);
}
}
formatDateTimeForInput(date) {
// Make sure date is a valid Date object
if (!(date instanceof Date) || isNaN(date.getTime())) {
// If it's a string that looks like a date, try to parse it
if (typeof date === 'string') {
date = new Date(date);
}
// If still not valid, return current time
if (!(date instanceof Date) || isNaN(date.getTime())) {
date = new Date();
}
}
return date.toISOString().slice(0, 16); // Format: YYYY-MM-DDTHH:mm
}
render() {
const { title, startTime, endTime, description, id } = this.state;
return this.createElement(
'div',
{ className: 'event-editor' },
// Navigation bar
new NavigationBar({
title: id ? 'Edit Event' : 'New Event',
leftButton: {
element: 'button',
props: {
className: 'nav-button cancel-button',
onClick: this.handleCancel,
'aria-label': 'Cancel',
},
content: 'Cancel',
},
rightButton: {
element: 'button',
props: {
className: 'nav-button save-button',
onClick: this.handleSave,
'aria-label': 'Save event',
},
content: 'Save',
},
}),
// Editor content
this.createElement(
'div',
{ className: 'event-form' },
// Title input
this.createElement('input', {
type: 'text',
className: 'event-title-input',
placeholder: 'Event title...',
value: title,
onInput: this.handleTitleChange,
ref: this.setTitleInputRef,
required: true,
}),
// Time inputs container
this.createElement(
'div',
{ className: 'time-container' },
// Start time input
this.createElement('input', {
type: 'datetime-local',
className: 'time-input',
value: startTime,
onInput: this.handleStartTimeChange,
ref: this.setStartTimeInputRef,
required: true,
}),
// End time input
this.createElement('input', {
type: 'datetime-local',
className: 'time-input',
value: endTime,
onInput: this.handleEndTimeChange,
ref: this.setEndTimeInputRef,
required: true,
})
),
// Description textarea
this.createElement('textarea', {
className: 'event-description-input',
placeholder: 'Add description...',
value: description,
onInput: this.handleDescriptionChange,
ref: this.setDescriptionInputRef,
}),
// Delete button (only for existing events)
id &&
this.createElement(
'button',
{
type: 'button',
className: 'delete-event-button',
onClick: this.handleDelete,
},
'Delete Event'
)
)
);
}
}

View File

@ -0,0 +1,118 @@
/**
* @fileoverview Main entry point for the Calendar application
*
* This module initializes the Calendar app UI, including:
* - Displaying the calendar view
* - Handling event creation, editing, and deletion via EventEditor
* - Managing event persistence via A3API
*/
/**
* Initializes and mounts the Calendar application.
* @param {HTMLElement} container - The DOM element to mount the app into.
*/
function initializeCalendarApp(container) {
const { events = [], selectedDate = new Date(), showEventEditor = false, currentEvent = null } = globalState.getState();
const appContainer = document.createElement('div');
appContainer.className = 'app-container';
appContainer.setAttribute('role', 'main');
appContainer.setAttribute('aria-label', 'Calendar');
// Check if we're viewing/editing a specific event
if (showEventEditor || currentEvent) {
// Show event editor
const eventEditor = new EventEditor({
event: currentEvent,
onSave: (savedEvent) => {
const currentEvents = globalState.getState().events || [];
let updatedEvents;
if (savedEvent.id && currentEvents.find(e => e.id === savedEvent.id)) {
// Update existing event
updatedEvents = currentEvents.map(e => e.id === savedEvent.id ? savedEvent : e);
} else {
// Add new event
updatedEvents = [savedEvent, ...currentEvents];
}
globalState.setState({
events: updatedEvents,
currentEvent: null,
showEventEditor: false
});
// Save to server
if (typeof saveCalendarEvent === 'function') {
saveCalendarEvent(savedEvent);
}
},
onCancel: () => {
globalState.setState({
currentEvent: null,
showEventEditor: false
});
},
onDelete: (eventId) => {
const currentEvents = globalState.getState().events || [];
const updatedEvents = currentEvents.filter(e => e.id !== eventId);
globalState.setState({
events: updatedEvents,
currentEvent: null,
showEventEditor: false
});
// Delete from server
if (typeof deleteCalendarEvent === 'function') {
deleteCalendarEvent(eventId);
}
}
});
eventEditor.mount(appContainer);
} else {
// Show calendar view
const navBar = new NavigationBar({
title: 'Calendar',
rightButton: {
element: 'button',
props: {
className: 'nav-button add-event-button',
onClick: () => {
globalState.setState({
showEventEditor: true,
currentEvent: null
});
},
'aria-label': 'Add Event'
},
content: '+'
}
});
navBar.mount(appContainer);
const calendar = new Calendar({
selectedDate: selectedDate,
events: events,
onDayClick: (date) => {
globalState.setState({
selectedDate: date,
currentEvent: null,
showEventEditor: false
});
},
onEventClick: (event) => {
globalState.setState({
currentEvent: event,
showEventEditor: true
});
}
});
calendar.mount(appContainer);
}
container.appendChild(appContainer);
}
// Make initialization function globally available
window.initializeCalendarApp = initializeCalendarApp;

View File

@ -0,0 +1,218 @@
/**
* @format
* @class AlarmClock
* @extends Component
* @description A component for managing alarms.
*/
class AlarmClock extends Component {
/**
* @constructor
* @param {Object} props - Component properties
*/
constructor(props = {}) {
super(props);
this.state = {
showAddForm: false,
newAlarmTime: '07:00',
newAlarmLabel: ''
};
// Bind methods
this.toggleAddForm = this.toggleAddForm.bind(this);
this.handleAddAlarm = this.handleAddAlarm.bind(this);
this.formatTime = this.formatTime.bind(this);
}
/**
* Toggle add alarm form
*/
toggleAddForm() {
// Use setState for form visibility changes as they need re-render
this.setState({
showAddForm: !this.state.showAddForm,
newAlarmTime: '07:00',
newAlarmLabel: ''
});
}
/**
* Handle adding a new alarm
*/
handleAddAlarm() {
const newAlarmTime = this.state.newAlarmTime;
const newAlarmLabel = this.state.newAlarmLabel;
if (newAlarmTime && this.props.onAddAlarm) {
this.props.onAddAlarm({
time: newAlarmTime,
label: newAlarmLabel || 'Alarm',
enabled: true,
days: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'] // Default to weekdays
});
// Use setState to hide form and reset state
this.setState({
showAddForm: false,
newAlarmTime: '07:00',
newAlarmLabel: ''
});
}
}
/**
* Format time for display
*/
formatTime(timeString) {
const [hours, minutes] = timeString.split(':');
if (this.props.format24h) {
return `${hours}:${minutes}`;
} else {
const hour = parseInt(hours);
const ampm = hour >= 12 ? 'PM' : 'AM';
const displayHour = hour % 12 || 12;
return `${displayHour}:${minutes} ${ampm}`;
}
}
/**
* Render add alarm form
*/
renderAddForm() {
if (!this.state.showAddForm) return null;
return this.createElement(
'div',
{ className: 'add-alarm-form' },
this.createElement('h3', {}, 'Add Alarm'),
this.createElement('input', {
type: 'time',
value: this.state.newAlarmTime,
onChange: (e) => {
// Update state directly to avoid re-render during input
this.state.newAlarmTime = e.target.value;
}
}),
this.createElement('input', {
type: 'text',
placeholder: 'Alarm label (optional)',
value: this.state.newAlarmLabel,
onChange: (e) => {
// Update state directly to avoid re-render during input
this.state.newAlarmLabel = e.target.value;
}
}),
this.createElement(
'div',
{ className: 'form-buttons' },
this.createElement(
'button',
{ onClick: this.toggleAddForm },
'Cancel'
),
this.createElement(
'button',
{ onClick: this.handleAddAlarm },
'Add Alarm'
)
)
);
}
/**
* Render alarms list
*/
renderAlarms() {
const { alarms } = this.props;
if (!alarms || alarms.length === 0) {
return this.createElement(
'div',
{ className: 'empty-state' },
this.createElement('p', {}, 'No alarms set. Tap + to add one.')
);
}
return this.createElement(
'div',
{ className: 'alarms-list' },
...alarms.map(alarm =>
this.createElement(
'div',
{
className: `alarm-item ${alarm.enabled ? 'enabled' : 'disabled'}`,
key: alarm.id
},
this.createElement(
'div',
{ className: 'alarm-info' },
this.createElement(
'div',
{ className: 'alarm-time' },
this.formatTime(alarm.time)
),
this.createElement(
'div',
{ className: 'alarm-label' },
alarm.label
),
alarm.days && this.createElement(
'div',
{ className: 'alarm-days' },
alarm.days.join(', ')
)
),
this.createElement(
'div',
{ className: 'alarm-controls' },
this.createElement(
'button',
{
className: 'toggle-alarm',
onClick: () => this.props.onToggleAlarm(alarm.id)
},
alarm.enabled ? 'On' : 'Off'
),
this.createElement(
'button',
{
className: 'remove-alarm',
onClick: () => this.props.onRemoveAlarm(alarm.id),
'aria-label': 'Delete alarm'
},
'Delete'
)
)
)
)
);
}
/**
* Render the alarm clock component
*/
render() {
return this.createElement(
'div',
{ className: 'alarm-clock' },
// Add alarm button
!this.state.showAddForm && this.createElement(
'button',
{
className: 'add-alarm-button',
onClick: this.toggleAddForm
},
'+ Add Alarm'
),
// Add alarm form
this.renderAddForm(),
// Alarms list
this.renderAlarms()
);
}
}

Some files were not shown because too many files have changed in this diff Show More