Remove Redis backend support #4
@ -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"
|
||||
302
arma/client/addons/phone/XEH_postInitClient.sqf
Normal file
@ -0,0 +1,302 @@
|
||||
#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);
|
||||
|
||||
// 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);
|
||||
|
||||
// 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(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);
|
||||
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"
|
||||
331
arma/client/addons/phone/functions/fnc_handleUIEvents.sqf
Normal file
@ -0,0 +1,331 @@
|
||||
#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::send::email": {
|
||||
private _toUid = _data get "toUid";
|
||||
private _subject = _data get "subject";
|
||||
private _body = _data get "body";
|
||||
|
||||
if (_toUid isNotEqualTo "" && _subject isNotEqualTo "" && _body isNotEqualTo "") then {
|
||||
["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::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.
|
||||
2925
arma/client/addons/phone/ui/_site/dist/app.bundle.css
vendored
Normal file
7470
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>
|
||||
259
arma/client/addons/phone/ui/_site/js/app.js
Normal file
@ -0,0 +1,259 @@
|
||||
/**
|
||||
* @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;
|
||||
|
||||
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: `Call ${selectedContact.name}?`,
|
||||
onClose: () => globalState.setState({ showModal: false, selectedContact: null }),
|
||||
onConfirm: () => {
|
||||
globalState.setState({
|
||||
phoneNumber: selectedContact.phone,
|
||||
showModal: false,
|
||||
selectedContact: null,
|
||||
currentApp: 'phone'
|
||||
});
|
||||
},
|
||||
children: [this.createElement('p', { role: 'alert' }, `Do you want to call ${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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,311 @@
|
||||
/**
|
||||
* @format
|
||||
* @class Stopwatch
|
||||
* @extends Component
|
||||
* @description A component that provides stopwatch functionality with lap timing.
|
||||
*/
|
||||
|
||||
class Stopwatch extends Component {
|
||||
/**
|
||||
* @constructor
|
||||
* @param {Object} props - Component properties
|
||||
* @param {boolean} props.format24h - Whether to use 24-hour format
|
||||
*/
|
||||
constructor(props = {}) {
|
||||
super(props);
|
||||
this.state = {
|
||||
time: 0, // Time in milliseconds
|
||||
isRunning: false,
|
||||
lapTimes: [],
|
||||
startTime: null
|
||||
};
|
||||
|
||||
// Bind methods
|
||||
this.start = this.start.bind(this);
|
||||
this.stop = this.stop.bind(this);
|
||||
this.reset = this.reset.bind(this);
|
||||
this.lap = this.lap.bind(this);
|
||||
this.updateTime = this.updateTime.bind(this);
|
||||
this.formatTime = this.formatTime.bind(this);
|
||||
|
||||
// Timer for updates
|
||||
this.interval = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component will unmount - clear intervals
|
||||
*/
|
||||
componentWillUnmount() {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the stopwatch
|
||||
*/
|
||||
start() {
|
||||
if (!this.state.isRunning) {
|
||||
const startTime = Date.now() - this.state.time;
|
||||
this.setState({
|
||||
isRunning: true,
|
||||
startTime: startTime
|
||||
});
|
||||
|
||||
this.interval = setInterval(this.updateTime, 10); // Update every 10ms for precision
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the stopwatch
|
||||
*/
|
||||
stop() {
|
||||
if (this.state.isRunning) {
|
||||
this.setState({ isRunning: false });
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the stopwatch
|
||||
*/
|
||||
reset() {
|
||||
this.setState({
|
||||
time: 0,
|
||||
isRunning: false,
|
||||
lapTimes: [],
|
||||
startTime: null
|
||||
});
|
||||
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a lap time
|
||||
*/
|
||||
lap() {
|
||||
if (this.state.isRunning) {
|
||||
const currentTime = this.state.time;
|
||||
const previousLapTime = this.state.lapTimes.length > 0
|
||||
? this.state.lapTimes[this.state.lapTimes.length - 1].totalTime
|
||||
: 0;
|
||||
|
||||
const lapTime = {
|
||||
id: generateId(),
|
||||
lapNumber: this.state.lapTimes.length + 1,
|
||||
lapTime: currentTime - previousLapTime,
|
||||
totalTime: currentTime,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
this.setState({
|
||||
lapTimes: [...this.state.lapTimes, lapTime]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current time
|
||||
*/
|
||||
updateTime() {
|
||||
if (this.state.isRunning && this.state.startTime) {
|
||||
const currentTime = Date.now() - this.state.startTime;
|
||||
// Update state directly to avoid re-render during stopwatch running
|
||||
this.state.time = currentTime;
|
||||
|
||||
// Update only the stopwatch time display element
|
||||
const stopwatchDisplay = document.querySelector('.stopwatch-time');
|
||||
if (stopwatchDisplay) {
|
||||
stopwatchDisplay.textContent = this.formatTime(currentTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time for display (HH:MM:SS.mmm)
|
||||
*/
|
||||
formatTime(milliseconds) {
|
||||
const totalSeconds = Math.floor(milliseconds / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const ms = Math.floor((milliseconds % 1000) / 10); // Show centiseconds
|
||||
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${ms.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the fastest and slowest lap times
|
||||
*/
|
||||
getLapStats() {
|
||||
if (this.state.lapTimes.length === 0) return null;
|
||||
|
||||
const lapTimes = this.state.lapTimes.map(lap => lap.lapTime);
|
||||
const fastest = Math.min(...lapTimes);
|
||||
const slowest = Math.max(...lapTimes);
|
||||
|
||||
return {
|
||||
fastest: this.state.lapTimes.find(lap => lap.lapTime === fastest),
|
||||
slowest: this.state.lapTimes.find(lap => lap.lapTime === slowest)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the main stopwatch display
|
||||
*/
|
||||
renderStopwatchDisplay() {
|
||||
const { time, isRunning } = this.state;
|
||||
|
||||
return this.createElement(
|
||||
'div',
|
||||
{ className: 'stopwatch-display' },
|
||||
this.createElement(
|
||||
'div',
|
||||
{
|
||||
className: `stopwatch-time ${isRunning ? 'running' : 'stopped'}`,
|
||||
'aria-live': 'polite',
|
||||
'aria-label': 'Stopwatch time'
|
||||
},
|
||||
this.formatTime(time)
|
||||
),
|
||||
this.createElement(
|
||||
'div',
|
||||
{ className: 'stopwatch-status' },
|
||||
isRunning ? 'Running' : (time > 0 ? 'Stopped' : 'Ready')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render control buttons
|
||||
*/
|
||||
renderControls() {
|
||||
const { isRunning, time } = this.state;
|
||||
|
||||
return this.createElement(
|
||||
'div',
|
||||
{ className: 'stopwatch-controls' },
|
||||
|
||||
// Start/Stop button
|
||||
this.createElement(
|
||||
'button',
|
||||
{
|
||||
className: `control-button ${isRunning ? 'stop-button' : 'start-button'}`,
|
||||
onClick: isRunning ? this.stop : this.start,
|
||||
'aria-label': isRunning ? 'Stop stopwatch' : 'Start stopwatch'
|
||||
},
|
||||
isRunning ? 'Stop' : 'Start'
|
||||
),
|
||||
|
||||
// Lap button (only when running)
|
||||
isRunning && this.createElement(
|
||||
'button',
|
||||
{
|
||||
className: 'control-button lap-button',
|
||||
onClick: this.lap,
|
||||
'aria-label': 'Record lap time'
|
||||
},
|
||||
'Lap'
|
||||
),
|
||||
|
||||
// Reset button (only when stopped and time > 0)
|
||||
!isRunning && time > 0 && this.createElement(
|
||||
'button',
|
||||
{
|
||||
className: 'control-button reset-button',
|
||||
onClick: this.reset,
|
||||
'aria-label': 'Reset stopwatch'
|
||||
},
|
||||
'Reset'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render lap times list
|
||||
*/
|
||||
renderLapTimes() {
|
||||
const { lapTimes } = this.state;
|
||||
|
||||
if (lapTimes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stats = this.getLapStats();
|
||||
|
||||
return this.createElement(
|
||||
'div',
|
||||
{ className: 'lap-times-section' },
|
||||
this.createElement(
|
||||
'h3',
|
||||
{ className: 'lap-times-title' },
|
||||
'Lap Times'
|
||||
),
|
||||
|
||||
// Lap times list
|
||||
this.createElement(
|
||||
'div',
|
||||
{ className: 'lap-times-list' },
|
||||
...lapTimes.slice().reverse().map(lap => {
|
||||
const isFastest = stats && lap.id === stats.fastest.id;
|
||||
const isSlowest = stats && lap.id === stats.slowest.id && lapTimes.length > 1;
|
||||
|
||||
return this.createElement(
|
||||
'div',
|
||||
{
|
||||
className: `lap-time-item ${
|
||||
isFastest ? 'fastest' : isSlowest ? 'slowest' : ''
|
||||
}`,
|
||||
key: lap.id
|
||||
},
|
||||
this.createElement(
|
||||
'div',
|
||||
{ className: 'lap-number' },
|
||||
`Lap ${lap.lapNumber}`
|
||||
),
|
||||
this.createElement(
|
||||
'div',
|
||||
{ className: 'lap-time' },
|
||||
this.formatTime(lap.lapTime)
|
||||
),
|
||||
this.createElement(
|
||||
'div',
|
||||
{ className: 'total-time' },
|
||||
this.formatTime(lap.totalTime)
|
||||
),
|
||||
(isFastest || isSlowest) && this.createElement(
|
||||
'div',
|
||||
{ className: 'lap-indicator' },
|
||||
isFastest ? 'Fastest' : 'Slowest'
|
||||
)
|
||||
);
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the stopwatch component
|
||||
*/
|
||||
render() {
|
||||
return this.createElement(
|
||||
'div',
|
||||
{ className: 'stopwatch' },
|
||||
|
||||
// Main stopwatch display
|
||||
this.renderStopwatchDisplay(),
|
||||
|
||||
// Control buttons
|
||||
this.renderControls(),
|
||||
|
||||
// Lap times
|
||||
this.renderLapTimes()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,245 @@
|
||||
/**
|
||||
* @format
|
||||
* @class Timer
|
||||
* @extends Component
|
||||
* @description A countdown timer component.
|
||||
*/
|
||||
|
||||
class Timer extends Component {
|
||||
/**
|
||||
* @constructor
|
||||
* @param {Object} props - Component properties
|
||||
*/
|
||||
constructor(props = {}) {
|
||||
super(props);
|
||||
this.state = {
|
||||
minutes: 5,
|
||||
seconds: 0,
|
||||
totalTime: 0,
|
||||
timeLeft: 0,
|
||||
isRunning: false,
|
||||
isFinished: false
|
||||
};
|
||||
|
||||
// Bind methods
|
||||
this.start = this.start.bind(this);
|
||||
this.pause = this.pause.bind(this);
|
||||
this.reset = this.reset.bind(this);
|
||||
this.setTime = this.setTime.bind(this);
|
||||
this.updateTimer = this.updateTimer.bind(this);
|
||||
this.formatTime = this.formatTime.bind(this);
|
||||
|
||||
// Timer interval
|
||||
this.interval = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component will unmount - clear intervals
|
||||
*/
|
||||
componentWillUnmount() {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set timer duration
|
||||
*/
|
||||
setTime(minutes, seconds) {
|
||||
const totalSeconds = minutes * 60 + seconds;
|
||||
this.setState({
|
||||
minutes,
|
||||
seconds,
|
||||
totalTime: totalSeconds,
|
||||
timeLeft: totalSeconds,
|
||||
isFinished: false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the timer
|
||||
*/
|
||||
start() {
|
||||
if (this.state.timeLeft > 0 && !this.state.isRunning) {
|
||||
this.setState({ isRunning: true });
|
||||
this.interval = setInterval(this.updateTimer, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause the timer
|
||||
*/
|
||||
pause() {
|
||||
this.setState({ isRunning: false });
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the timer
|
||||
*/
|
||||
reset() {
|
||||
this.setState({
|
||||
timeLeft: this.state.totalTime,
|
||||
isRunning: false,
|
||||
isFinished: false
|
||||
});
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update timer countdown
|
||||
*/
|
||||
updateTimer() {
|
||||
if (this.state.timeLeft > 0) {
|
||||
// Update state directly to avoid re-render during countdown
|
||||
this.state.timeLeft = this.state.timeLeft - 1;
|
||||
|
||||
// Update only the timer display element
|
||||
const timerDisplay = document.querySelector('.timer-time');
|
||||
if (timerDisplay) {
|
||||
timerDisplay.textContent = this.formatTime(this.state.timeLeft);
|
||||
}
|
||||
} else {
|
||||
// Timer finished - this needs a full re-render
|
||||
this.setState({
|
||||
isRunning: false,
|
||||
isFinished: true
|
||||
});
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time for display
|
||||
*/
|
||||
formatTime(totalSeconds) {
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render timer controls
|
||||
*/
|
||||
renderControls() {
|
||||
const { isRunning, timeLeft, isFinished } = this.state;
|
||||
|
||||
return this.createElement(
|
||||
'div',
|
||||
{ className: 'timer-controls' },
|
||||
|
||||
// Start/Pause button
|
||||
timeLeft > 0 && !isFinished && this.createElement(
|
||||
'button',
|
||||
{
|
||||
className: `control-button ${isRunning ? 'pause-button' : 'start-button'}`,
|
||||
onClick: isRunning ? this.pause : this.start
|
||||
},
|
||||
isRunning ? 'Pause' : 'Start'
|
||||
),
|
||||
|
||||
// Reset button
|
||||
(timeLeft !== this.state.totalTime || isFinished) && this.createElement(
|
||||
'button',
|
||||
{
|
||||
className: 'control-button reset-button',
|
||||
onClick: this.reset
|
||||
},
|
||||
'Reset'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render time setters
|
||||
*/
|
||||
renderTimeSetters() {
|
||||
if (this.state.isRunning) return null;
|
||||
|
||||
return this.createElement(
|
||||
'div',
|
||||
{ className: 'time-setters' },
|
||||
this.createElement(
|
||||
'div',
|
||||
{ className: 'time-setter' },
|
||||
this.createElement('label', {}, 'Minutes'),
|
||||
this.createElement('input', {
|
||||
type: 'number',
|
||||
min: '0',
|
||||
max: '59',
|
||||
value: this.state.minutes,
|
||||
onChange: (e) => {
|
||||
// Update state directly to avoid re-render during input
|
||||
const minutes = parseInt(e.target.value) || 0;
|
||||
this.state.minutes = minutes;
|
||||
this.setTime(minutes, this.state.seconds);
|
||||
}
|
||||
})
|
||||
),
|
||||
this.createElement(
|
||||
'div',
|
||||
{ className: 'time-setter' },
|
||||
this.createElement('label', {}, 'Seconds'),
|
||||
this.createElement('input', {
|
||||
type: 'number',
|
||||
min: '0',
|
||||
max: '59',
|
||||
value: this.state.seconds,
|
||||
onChange: (e) => {
|
||||
// Update state directly to avoid re-render during input
|
||||
const seconds = parseInt(e.target.value) || 0;
|
||||
this.state.seconds = seconds;
|
||||
this.setTime(this.state.minutes, seconds);
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the timer component
|
||||
*/
|
||||
render() {
|
||||
const { timeLeft, isFinished } = this.state;
|
||||
|
||||
return this.createElement(
|
||||
'div',
|
||||
{ className: 'timer' },
|
||||
|
||||
// Timer display
|
||||
this.createElement(
|
||||
'div',
|
||||
{ className: 'timer-display' },
|
||||
this.createElement(
|
||||
'div',
|
||||
{
|
||||
className: `timer-time ${isFinished ? 'finished' : ''}`,
|
||||
'aria-live': 'polite'
|
||||
},
|
||||
this.formatTime(timeLeft)
|
||||
),
|
||||
this.createElement(
|
||||
'div',
|
||||
{ className: 'timer-status' },
|
||||
isFinished ? 'Time\'s up!' : 'Timer'
|
||||
)
|
||||
),
|
||||
|
||||
// Time setters
|
||||
this.renderTimeSetters(),
|
||||
|
||||
// Controls
|
||||
this.renderControls()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,433 @@
|
||||
/**
|
||||
* @format
|
||||
* @class WorldClock
|
||||
* @extends Component
|
||||
* @description A component that displays multiple world clocks for different time zones.
|
||||
*/
|
||||
|
||||
class WorldClock extends Component {
|
||||
/**
|
||||
* @constructor
|
||||
* @param {Object} props - Component properties
|
||||
* @param {Array} props.clocks - Array of world clock objects
|
||||
* @param {boolean} props.format24h - Whether to use 24-hour format
|
||||
* @param {Function} props.onAddClock - Callback when adding a new clock
|
||||
* @param {Function} props.onRemoveClock - Callback when removing a clock
|
||||
*/
|
||||
constructor(props = {}) {
|
||||
super(props);
|
||||
this.state = {
|
||||
currentTime: new Date(),
|
||||
showAddForm: false,
|
||||
selectedTimezone: ''
|
||||
};
|
||||
|
||||
// Bind methods
|
||||
this.updateTime = this.updateTime.bind(this);
|
||||
this.toggleAddForm = this.toggleAddForm.bind(this);
|
||||
this.handleAddClock = this.handleAddClock.bind(this);
|
||||
this.handleRemoveClock = this.handleRemoveClock.bind(this);
|
||||
this.formatTime = this.formatTime.bind(this);
|
||||
this.getTimezoneTime = this.getTimezoneTime.bind(this);
|
||||
|
||||
// Timer for real-time updates
|
||||
this.timeUpdateInterval = null;
|
||||
|
||||
// Popular time zones
|
||||
this.popularTimezones = [
|
||||
'America/New_York',
|
||||
'America/Los_Angeles',
|
||||
'America/Chicago',
|
||||
'Europe/London',
|
||||
'Europe/Paris',
|
||||
'Europe/Berlin',
|
||||
'Asia/Tokyo',
|
||||
'Asia/Shanghai',
|
||||
'Asia/Kolkata',
|
||||
'Australia/Sydney',
|
||||
'Pacific/Auckland',
|
||||
'Africa/Cairo',
|
||||
'America/Sao_Paulo',
|
||||
'Asia/Dubai',
|
||||
'Europe/Moscow'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Component mounted - start time updates
|
||||
*/
|
||||
componentDidMount() {
|
||||
this.timeUpdateInterval = setInterval(this.updateTime, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component will unmount - clear intervals
|
||||
*/
|
||||
componentWillUnmount() {
|
||||
if (this.timeUpdateInterval) {
|
||||
clearInterval(this.timeUpdateInterval);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update current time
|
||||
*/
|
||||
updateTime() {
|
||||
// Update state directly to avoid re-render during time updates
|
||||
this.state.currentTime = new Date();
|
||||
const currentTime = this.state.currentTime;
|
||||
|
||||
// Update local time display
|
||||
const localTimeElement = document.querySelector('.local-time');
|
||||
if (localTimeElement) {
|
||||
const timeOptions = {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: !this.props.format24h
|
||||
};
|
||||
localTimeElement.textContent = currentTime.toLocaleTimeString('en-US', timeOptions);
|
||||
}
|
||||
|
||||
// Update all world clock time displays
|
||||
const worldClockItems = document.querySelectorAll('.world-clock-item');
|
||||
worldClockItems.forEach((clockItem, index) => {
|
||||
const clockTimeElement = clockItem.querySelector('.clock-time');
|
||||
const clockDateElement = clockItem.querySelector('.clock-date');
|
||||
|
||||
if (clockTimeElement && this.props.clocks && this.props.clocks[index]) {
|
||||
const timezone = this.props.clocks[index].timezone;
|
||||
|
||||
// Update time
|
||||
try {
|
||||
const timeOptions = {
|
||||
timeZone: timezone,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: !this.props.format24h
|
||||
};
|
||||
clockTimeElement.textContent = currentTime.toLocaleTimeString('en-US', timeOptions);
|
||||
} catch (error) {
|
||||
clockTimeElement.textContent = '--:--:--';
|
||||
}
|
||||
|
||||
// Update date
|
||||
if (clockDateElement) {
|
||||
try {
|
||||
const dateOptions = {
|
||||
timeZone: timezone,
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
};
|
||||
clockDateElement.textContent = currentTime.toLocaleDateString('en-US', dateOptions);
|
||||
} catch (error) {
|
||||
clockDateElement.textContent = 'Invalid date';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle add clock form
|
||||
*/
|
||||
toggleAddForm() {
|
||||
// Use setState for form visibility changes as they need re-render
|
||||
this.setState({
|
||||
showAddForm: !this.state.showAddForm,
|
||||
selectedTimezone: '' // Reset selection when toggling
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle adding a new clock
|
||||
*/
|
||||
handleAddClock() {
|
||||
const selectedTimezone = this.state.selectedTimezone;
|
||||
if (selectedTimezone && this.props.onAddClock) {
|
||||
this.props.onAddClock(selectedTimezone);
|
||||
// Use setState to hide form and reset state
|
||||
this.setState({
|
||||
showAddForm: false,
|
||||
selectedTimezone: ''
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle removing a clock
|
||||
*/
|
||||
handleRemoveClock(clockId) {
|
||||
if (this.props.onRemoveClock) {
|
||||
this.props.onRemoveClock(clockId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time for a specific timezone
|
||||
*/
|
||||
getTimezoneTime(timezone) {
|
||||
try {
|
||||
return new Date().toLocaleString('en-US', {
|
||||
timeZone: timezone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: !this.props.format24h
|
||||
});
|
||||
} catch (error) {
|
||||
return 'Invalid timezone';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time for display
|
||||
*/
|
||||
formatTime(date, timezone) {
|
||||
try {
|
||||
const options = {
|
||||
timeZone: timezone,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: !this.props.format24h
|
||||
};
|
||||
return date.toLocaleTimeString('en-US', options);
|
||||
} catch (error) {
|
||||
return '--:--:--';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get date for timezone
|
||||
*/
|
||||
getTimezoneDate(timezone) {
|
||||
try {
|
||||
return new Date().toLocaleDateString('en-US', {
|
||||
timeZone: timezone,
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
} catch (error) {
|
||||
return 'Invalid date';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render local time section
|
||||
*/
|
||||
renderLocalTime() {
|
||||
const { currentTime } = this.state;
|
||||
const { format24h } = this.props;
|
||||
|
||||
const timeOptions = {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: !format24h
|
||||
};
|
||||
|
||||
const dateOptions = {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
};
|
||||
|
||||
return this.createElement(
|
||||
'div',
|
||||
{ className: 'local-time-section' },
|
||||
this.createElement(
|
||||
'h2',
|
||||
{ className: 'local-time-label' },
|
||||
'Local Time'
|
||||
),
|
||||
this.createElement(
|
||||
'div',
|
||||
{ className: 'local-time-display' },
|
||||
this.createElement(
|
||||
'div',
|
||||
{ className: 'local-time' },
|
||||
currentTime.toLocaleTimeString('en-US', timeOptions)
|
||||
),
|
||||
this.createElement(
|
||||
'div',
|
||||
{ className: 'local-date' },
|
||||
currentTime.toLocaleDateString('en-US', dateOptions)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render add clock form
|
||||
*/
|
||||
renderAddForm() {
|
||||
if (!this.state.showAddForm) return null;
|
||||
|
||||
return this.createElement(
|
||||
'div',
|
||||
{ className: 'add-clock-form' },
|
||||
this.createElement(
|
||||
'h3',
|
||||
{},
|
||||
'Add World Clock'
|
||||
),
|
||||
this.createElement(
|
||||
'select',
|
||||
{
|
||||
className: 'timezone-select',
|
||||
value: this.state.selectedTimezone,
|
||||
onChange: (e) => {
|
||||
// Update state directly to avoid re-render during selection
|
||||
this.state.selectedTimezone = e.target.value;
|
||||
|
||||
// Update button disabled state directly
|
||||
const addButton = document.querySelector('.add-button');
|
||||
if (addButton) {
|
||||
addButton.disabled = !e.target.value;
|
||||
}
|
||||
}
|
||||
},
|
||||
this.createElement('option', { value: '' }, 'Select a timezone...'),
|
||||
...this.popularTimezones.map(tz =>
|
||||
this.createElement(
|
||||
'option',
|
||||
{ value: tz, key: tz },
|
||||
tz.replace('_', ' ').split('/').join(' - ')
|
||||
)
|
||||
)
|
||||
),
|
||||
this.createElement(
|
||||
'div',
|
||||
{ className: 'form-buttons' },
|
||||
this.createElement(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
onClick: this.toggleAddForm,
|
||||
className: 'cancel-button'
|
||||
},
|
||||
'Cancel'
|
||||
),
|
||||
this.createElement(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
onClick: this.handleAddClock,
|
||||
className: 'add-button',
|
||||
disabled: !this.state.selectedTimezone
|
||||
},
|
||||
'Add Clock'
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render world clocks list
|
||||
*/
|
||||
renderWorldClocks() {
|
||||
const { clocks } = this.props;
|
||||
const { currentTime } = this.state;
|
||||
|
||||
if (!clocks || clocks.length === 0) {
|
||||
return this.createElement(
|
||||
'div',
|
||||
{ className: 'empty-state' },
|
||||
this.createElement(
|
||||
'p',
|
||||
{},
|
||||
'No world clocks added yet. Tap + to add one.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return this.createElement(
|
||||
'div',
|
||||
{ className: 'world-clocks-list' },
|
||||
...clocks.map(clock =>
|
||||
this.createElement(
|
||||
'div',
|
||||
{
|
||||
className: 'world-clock-item',
|
||||
key: clock.id
|
||||
},
|
||||
this.createElement(
|
||||
'div',
|
||||
{ className: 'clock-info' },
|
||||
this.createElement(
|
||||
'div',
|
||||
{ className: 'clock-city' },
|
||||
clock.city
|
||||
),
|
||||
this.createElement(
|
||||
'div',
|
||||
{ className: 'clock-timezone' },
|
||||
clock.timezone.split('/').join(' / ')
|
||||
)
|
||||
),
|
||||
this.createElement(
|
||||
'div',
|
||||
{ className: 'clock-time-info' },
|
||||
this.createElement(
|
||||
'div',
|
||||
{ className: 'clock-time' },
|
||||
this.formatTime(currentTime, clock.timezone)
|
||||
),
|
||||
this.createElement(
|
||||
'div',
|
||||
{ className: 'clock-date' },
|
||||
this.getTimezoneDate(clock.timezone)
|
||||
)
|
||||
),
|
||||
this.createElement(
|
||||
'button',
|
||||
{
|
||||
className: 'remove-clock-button',
|
||||
onClick: () => this.handleRemoveClock(clock.id),
|
||||
'aria-label': `Remove ${clock.city} clock`
|
||||
},
|
||||
'Remove'
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the world clock component
|
||||
*/
|
||||
render() {
|
||||
return this.createElement(
|
||||
'div',
|
||||
{ className: 'world-clock' },
|
||||
|
||||
// Local time section
|
||||
this.renderLocalTime(),
|
||||
|
||||
// Add clock button
|
||||
!this.state.showAddForm && this.createElement(
|
||||
'button',
|
||||
{
|
||||
className: 'add-world-clock-button',
|
||||
onClick: this.toggleAddForm
|
||||
},
|
||||
'+ Add World Clock'
|
||||
),
|
||||
|
||||
// Add clock form
|
||||
this.renderAddForm(),
|
||||
|
||||
// World clocks list
|
||||
this.renderWorldClocks()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||