Merge pull request 'Remove Redis backend support' (#4) from feature/surrealdb-storage into master
Reviewed-on: #4
@ -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
|
||||
```
|
||||
|
||||
@ -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
@ -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.
|
||||
|
||||
@ -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]; };
|
||||
|
||||
1
arma/client/addons/phone/$PBOPREFIX$
Normal file
@ -0,0 +1 @@
|
||||
forge\forge_client\addons\phone
|
||||
19
arma/client/addons/phone/CfgEventHandlers.hpp
Normal 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));
|
||||
};
|
||||
};
|
||||
4
arma/client/addons/phone/README.md
Normal 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.
|
||||
3
arma/client/addons/phone/XEH_PREP.hpp
Normal file
@ -0,0 +1,3 @@
|
||||
PREP(handleUIEvents);
|
||||
PREP(initClass);
|
||||
PREP(openUI);
|
||||
1
arma/client/addons/phone/XEH_postInit.sqf
Normal file
@ -0,0 +1 @@
|
||||
#include "script_component.hpp"
|
||||
340
arma/client/addons/phone/XEH_postInitClient.sqf
Normal 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);
|
||||
9
arma/client/addons/phone/XEH_preInit.sqf
Normal 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"
|
||||
1
arma/client/addons/phone/XEH_preInitClient.sqf
Normal file
@ -0,0 +1 @@
|
||||
#include "script_component.hpp"
|
||||
2
arma/client/addons/phone/XEH_preStart.sqf
Normal file
@ -0,0 +1,2 @@
|
||||
#include "script_component.hpp"
|
||||
#include "XEH_PREP.hpp"
|
||||
21
arma/client/addons/phone/config.cpp
Normal 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"
|
||||
351
arma/client/addons/phone/functions/fnc_handleUIEvents.sqf
Normal 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;
|
||||
293
arma/client/addons/phone/functions/fnc_initClass.sqf
Normal 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)
|
||||
31
arma/client/addons/phone/functions/fnc_openUI.sqf
Normal 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;
|
||||
8
arma/client/addons/phone/initKeybinds.inc.sqf
Normal 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);
|
||||
9
arma/client/addons/phone/script_component.hpp
Normal 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"
|
||||
14
arma/client/addons/phone/stringtable.xml
Normal 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>
|
||||
265
arma/client/addons/phone/ui/RscCommon.hpp
Normal 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;
|
||||
22
arma/client/addons/phone/ui/RscPhone.hpp
Normal 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};
|
||||
};
|
||||
};
|
||||
};
|
||||
156
arma/client/addons/phone/ui/_site/README.md
Normal 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.
|
||||
2986
arma/client/addons/phone/ui/_site/dist/app.bundle.css
vendored
Normal file
7928
arma/client/addons/phone/ui/_site/dist/app.bundle.js
vendored
Normal file
BIN
arma/client/addons/phone/ui/_site/images/bg/bgdark_01_ca.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
arma/client/addons/phone/ui/_site/images/bg/bgdark_02_ca.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
arma/client/addons/phone/ui/_site/images/bg/bglight_01_ca.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
arma/client/addons/phone/ui/_site/images/bg/bglight_02_ca.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
arma/client/addons/phone/ui/_site/images/dark/AppStore.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
arma/client/addons/phone/ui/_site/images/dark/Calendar.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
arma/client/addons/phone/ui/_site/images/dark/Camera.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
arma/client/addons/phone/ui/_site/images/dark/Clock.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
arma/client/addons/phone/ui/_site/images/dark/Contacts.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
arma/client/addons/phone/ui/_site/images/dark/Mail.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
arma/client/addons/phone/ui/_site/images/dark/Message.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
arma/client/addons/phone/ui/_site/images/dark/Notes.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
arma/client/addons/phone/ui/_site/images/dark/Phone.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
arma/client/addons/phone/ui/_site/images/dark/Photos.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
arma/client/addons/phone/ui/_site/images/dark/Reminders.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
arma/client/addons/phone/ui/_site/images/dark/Safari.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
arma/client/addons/phone/ui/_site/images/dark/Settings.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
arma/client/addons/phone/ui/_site/images/dark/Weather.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
arma/client/addons/phone/ui/_site/images/dark/iCloud.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
arma/client/addons/phone/ui/_site/images/iPhoneIcons.xcf
Normal file
BIN
arma/client/addons/phone/ui/_site/images/light/AppStore.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
arma/client/addons/phone/ui/_site/images/light/Calendar.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
arma/client/addons/phone/ui/_site/images/light/Call.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
arma/client/addons/phone/ui/_site/images/light/Camera.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
arma/client/addons/phone/ui/_site/images/light/Clock.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
arma/client/addons/phone/ui/_site/images/light/Contact.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
arma/client/addons/phone/ui/_site/images/light/Contacts.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
arma/client/addons/phone/ui/_site/images/light/HangUp.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
arma/client/addons/phone/ui/_site/images/light/Mail.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
arma/client/addons/phone/ui/_site/images/light/Message.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
arma/client/addons/phone/ui/_site/images/light/Notes.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
arma/client/addons/phone/ui/_site/images/light/Phone.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
arma/client/addons/phone/ui/_site/images/light/Photos.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
arma/client/addons/phone/ui/_site/images/light/Reminders.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
arma/client/addons/phone/ui/_site/images/light/Safari.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
arma/client/addons/phone/ui/_site/images/light/Settings.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
arma/client/addons/phone/ui/_site/images/light/Weather.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
arma/client/addons/phone/ui/_site/images/light/iCloud.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
29
arma/client/addons/phone/ui/_site/index.html
Normal 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>
|
||||
324
arma/client/addons/phone/ui/_site/js/app.js
Normal 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.`)]
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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'
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
118
arma/client/addons/phone/ui/_site/js/apps/calendar/index.js
Normal 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;
|
||||
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||